feat: add manifest profile discovery

This commit is contained in:
2026-05-30 02:18:42 +09:00
parent 06c778a725
commit ee7147b355
8 changed files with 822 additions and 63 deletions
+5 -5
View File
@@ -27,11 +27,11 @@ pub struct SpawnConfig {
/// (`manifest::paths::pod_runtime_dir`) の解決と、ready 行に乗る
/// 名前との突き合わせに使う。
pub pod_name: String,
/// Optional Nix profile path. When present the child is launched with
/// Optional Nix profile selector. When present the child is launched with
/// `--profile` and the TOML overlay is not passed; the Pod name is supplied
/// through `--profile-pod-name` so profile evaluation stays separate from
/// manifest layer merging and from `--pod` restore semantics.
pub profile_path: Option<PathBuf>,
pub profile: Option<String>,
/// `--overlay` で pod に渡す TOML 文字列。
pub overlay_toml: String,
/// pod の current_dir。
@@ -117,16 +117,16 @@ where
.stdout(Stdio::null())
.stderr(Stdio::from(stderr_file))
.process_group(0);
if let Some(profile_path) = &config.profile_path {
if let Some(profile) = &config.profile {
command
.arg("--profile")
.arg(profile_path)
.arg(profile)
.arg("--profile-pod-name")
.arg(&config.pod_name);
} else {
command.arg("--overlay").arg(&config.overlay_toml);
}
if config.resume_by_pod_name && config.profile_path.is_none() {
if config.resume_by_pod_name && config.profile.is_none() {
command.arg("--pod").arg(&config.pod_name);
}
if let Some(id) = config.resume_from {
+3 -2
View File
@@ -18,8 +18,9 @@ pub use paths::{
user_manifest_path, user_manifest_path_from_env, user_manifest_path_with_env_override,
};
pub use profile::{
NixProfileResolver, ProfileError, ProfileManifestSnapshot, ProfileMetadata, ProfileSelector,
ProfileSource, ResolvedProfile, resolve_profile_artifact,
NixProfileResolver, ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata,
ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource, ProfileSelector, ProfileSource,
ResolvedProfile, resolve_profile_artifact,
};
pub use protocol::{Permission, ScopeRule};
pub use scope::{Scope, ScopeError, SharedScope};
+30
View File
@@ -33,6 +33,9 @@ use std::path::PathBuf;
/// auto-discovered user manifest path.
pub const USER_MANIFEST_ENV: &str = "INSOMNIA_USER_MANIFEST";
/// Environment variable that points at installed project resources.
pub const RESOURCE_DIR_ENV: &str = "INSOMNIA_RESOURCE_DIR";
/// 設定ディレクトリ。`manifest.toml`, `providers.toml`, `models.toml`,
/// `prompts/` などが置かれる。
pub fn config_dir() -> Option<PathBuf> {
@@ -114,6 +117,32 @@ pub fn user_prompts_dir() -> Option<PathBuf> {
Some(config_dir()?.join("prompts"))
}
/// Root resource directory used for bundled prompts/Nix support files.
pub fn resource_dir() -> Option<PathBuf> {
if let Some(p) = env_path(RESOURCE_DIR_ENV) {
return Some(p);
}
if let Ok(exe) = std::env::current_exe() {
if let Some(prefix) = exe.parent().and_then(|bin| bin.parent()) {
let installed = prefix.join("share").join("insomnia").join("resources");
if installed.exists() {
return Some(installed);
}
}
}
Some(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("resources"),
)
}
/// Bundled profile registry directory. Missing directories are treated as an
/// empty builtin registry by discovery.
pub fn builtin_profiles_dir() -> Option<PathBuf> {
Some(resource_dir()?.join("nix").join("profiles"))
}
/// `<config_dir>/prompts.toml` — user prompt pack。
pub fn user_pack_file() -> Option<PathBuf> {
Some(config_dir()?.join("prompts.toml"))
@@ -192,6 +221,7 @@ mod tests {
"INSOMNIA_DATA_DIR",
"INSOMNIA_RUNTIME_DIR",
"INSOMNIA_USER_MANIFEST",
"INSOMNIA_RESOURCE_DIR",
"INSOMNIA_HOME",
"XDG_CONFIG_HOME",
"XDG_RUNTIME_DIR",
+607 -13
View File
@@ -4,34 +4,316 @@
//! resolved artifact. Rust consumes the evaluated JSON artifact directly and
//! validates it into the existing [`crate::PodManifest`] runtime contract.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use serde::{Deserialize, Serialize};
use crate::{PodManifest, PodManifestConfig, ResolveError};
use crate::{PodManifest, PodManifestConfig, ResolveError, paths};
const PROFILE_FORMAT_V1: &str = "insomnia.nix-profile.v1";
/// Registry source for discovered profiles.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProfileRegistrySource {
Builtin,
User,
Project,
}
impl ProfileRegistrySource {
pub fn as_str(self) -> &'static str {
match self {
Self::Builtin => "builtin",
Self::User => "user",
Self::Project => "project",
}
}
fn parse(raw: &str) -> Option<Self> {
match raw {
"builtin" => Some(Self::Builtin),
"user" => Some(Self::User),
"project" => Some(Self::Project),
_ => None,
}
}
}
impl std::fmt::Display for ProfileRegistrySource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
/// User selection of a profile source.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ProfileSelector {
/// A local Nix expression evaluated with `nix eval --json --file <path>`.
Path { path: PathBuf },
/// A named profile discovered from builtin/user/project registries.
Named {
#[serde(default, skip_serializing_if = "Option::is_none")]
source: Option<ProfileRegistrySource>,
name: String,
},
/// The effective default from the discovered profile registry.
Default,
}
impl ProfileSelector {
pub fn path(path: impl Into<PathBuf>) -> Self {
Self::Path { path: path.into() }
}
pub fn named(name: impl Into<String>) -> Self {
Self::Named {
source: None,
name: name.into(),
}
}
pub fn source_named(source: ProfileRegistrySource, name: impl Into<String>) -> Self {
Self::Named {
source: Some(source),
name: name.into(),
}
}
/// Parse the CLI/TUI `--profile` argument.
///
/// `path:<path>` always selects an explicit path. `builtin:<name>`,
/// `user:<name>`, and `project:<name>` require the given registry source.
/// Unqualified path-like values (containing `/`, starting with `.`, or ending
/// in `.nix`) remain compatible with the original explicit-path flow; other
/// values are discovered names. `default` asks discovery for the effective
/// default profile.
pub fn parse_cli(raw: &str) -> Self {
if raw == "default" {
return Self::Default;
}
if let Some(path) = raw.strip_prefix("path:") {
return Self::path(path);
}
if let Some((prefix, name)) = raw.split_once(':') {
if let Some(source) = ProfileRegistrySource::parse(prefix) {
return Self::source_named(source, name);
}
}
if raw.contains('/') || raw.starts_with('.') || raw.ends_with(".nix") {
Self::path(raw)
} else {
Self::named(raw)
}
}
pub fn display_label(&self) -> String {
match self {
Self::Path { path } => path.display().to_string(),
Self::Named { source, name } => match source {
Some(source) => format!("{source}:{name}"),
None => name.clone(),
},
Self::Default => "default".to_string(),
}
}
}
/// Profile source recorded with a resolved artifact.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ProfileSource {
Path { path: PathBuf },
Path {
path: PathBuf,
},
Registry {
source: ProfileRegistrySource,
name: String,
path: PathBuf,
},
}
/// One profile discovered from a registry source.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProfileRegistryEntry {
pub source: ProfileRegistrySource,
pub name: String,
pub path: PathBuf,
pub description: Option<String>,
pub is_default: bool,
}
impl ProfileRegistryEntry {
pub fn qualified_name(&self) -> String {
format!("{}:{}", self.source, self.name)
}
}
/// Discovered profile registry. User/project manifests contribute only profile
/// discovery metadata (entries, aliases, defaults); those files are not merged
/// into the selected profile's runtime manifest.
#[derive(Debug, Clone, Default)]
pub struct ProfileRegistry {
entries: Vec<ProfileRegistryEntry>,
aliases: Vec<ProfileAlias>,
default: Option<ProfileDefault>,
}
impl ProfileRegistry {
pub fn entries(&self) -> &[ProfileRegistryEntry] {
&self.entries
}
pub fn default_entry(&self) -> Result<&ProfileRegistryEntry, ProfileError> {
let default = self
.default
.as_ref()
.ok_or(ProfileError::NoDefaultProfile)?;
self.select_named(default.source, &default.name)
}
pub fn select(
&self,
selector: &ProfileSelector,
) -> Result<&ProfileRegistryEntry, ProfileError> {
match selector {
ProfileSelector::Path { .. } => Err(ProfileError::InvalidArtifact(
"path selectors are not registry entries".to_string(),
)),
ProfileSelector::Default => self.default_entry(),
ProfileSelector::Named { source, name } => self.select_named(*source, name),
}
}
fn select_named(
&self,
source: Option<ProfileRegistrySource>,
name: &str,
) -> Result<&ProfileRegistryEntry, ProfileError> {
let alias_matches: Vec<_> = self
.aliases
.iter()
.filter(|alias| alias.name == name && source.is_none_or(|s| s == alias.source))
.collect();
match alias_matches.as_slice() {
[alias] => return self.select_named(alias.target_source, &alias.target_name),
[] => {}
_ => {
return Err(ProfileError::AmbiguousProfileName {
name: name.to_string(),
matches: alias_matches
.iter()
.map(|alias| format!("{}:{}", alias.source, alias.name))
.collect(),
});
}
}
let matches: Vec<_> = self
.entries
.iter()
.filter(|entry| entry.name == name && source.is_none_or(|s| s == entry.source))
.collect();
match matches.as_slice() {
[entry] => Ok(*entry),
[] => Err(ProfileError::ProfileNotFound {
selector: match source {
Some(source) => format!("{source}:{name}"),
None => name.to_string(),
},
}),
_ => Err(ProfileError::AmbiguousProfileName {
name: name.to_string(),
matches: matches.iter().map(|entry| entry.qualified_name()).collect(),
}),
}
}
fn push_entry(&mut self, entry: ProfileRegistryEntry) {
self.entries.push(entry);
}
fn push_alias(&mut self, alias: ProfileAlias) {
self.aliases.push(alias);
}
fn set_default(&mut self, default: ProfileDefault) {
self.default = Some(default);
}
fn mark_default_flags(&mut self) {
let Some(default) = self.default.clone() else {
return;
};
let Some(source) = default.source else {
return;
};
for entry in &mut self.entries {
entry.is_default = entry.source == source && entry.name == default.name;
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ProfileAlias {
source: ProfileRegistrySource,
name: String,
target_source: Option<ProfileRegistrySource>,
target_name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ProfileDefault {
source: Option<ProfileRegistrySource>,
name: String,
}
/// Filesystem-backed profile discovery.
#[derive(Debug, Clone)]
pub struct ProfileDiscovery {
builtin_dir: Option<PathBuf>,
user_manifest: Option<PathBuf>,
project_manifest: Option<PathBuf>,
}
impl ProfileDiscovery {
pub fn for_cwd(cwd: &Path) -> Self {
Self {
builtin_dir: paths::builtin_profiles_dir(),
user_manifest: paths::user_manifest_path_with_env_override(),
project_manifest: crate::find_project_manifest_from(cwd),
}
}
pub fn with_sources(
builtin_dir: Option<PathBuf>,
user_manifest: Option<PathBuf>,
project_manifest: Option<PathBuf>,
) -> Self {
Self {
builtin_dir,
user_manifest,
project_manifest,
}
}
pub fn discover(&self) -> Result<ProfileRegistry, ProfileError> {
let mut registry = ProfileRegistry::default();
if let Some(dir) = &self.builtin_dir {
discover_profile_dir(&mut registry, ProfileRegistrySource::Builtin, dir)?;
}
if let Some(path) = &self.user_manifest {
load_profile_config_manifest(&mut registry, ProfileRegistrySource::User, path)?;
}
if let Some(path) = &self.project_manifest {
load_profile_config_manifest(&mut registry, ProfileRegistrySource::Project, path)?;
}
registry.mark_default_flags();
Ok(registry)
}
}
/// Metadata optionally emitted by `mkProfile`.
@@ -93,11 +375,36 @@ impl NixProfileResolver {
pub fn resolve(&self, selector: &ProfileSelector) -> Result<ResolvedProfile, ProfileError> {
match selector {
ProfileSelector::Path { path } => self.resolve_path(path),
ProfileSelector::Path { path } => self.resolve_path(
path,
ProfileSource::Path {
path: absolutize(path)?,
},
),
ProfileSelector::Named { .. } | ProfileSelector::Default => {
let cwd = std::env::current_dir().map_err(|source| ProfileError::CommandIo {
path: PathBuf::from("."),
source,
})?;
let registry = ProfileDiscovery::for_cwd(&cwd).discover()?;
let entry = registry.select(selector)?.clone();
self.resolve_path(
&entry.path,
ProfileSource::Registry {
source: entry.source,
name: entry.name,
path: absolutize(&entry.path)?,
},
)
}
}
}
fn resolve_path(&self, path: &Path) -> Result<ResolvedProfile, ProfileError> {
fn resolve_path(
&self,
path: &Path,
source: ProfileSource,
) -> Result<ResolvedProfile, ProfileError> {
let absolute_path = absolutize(path)?;
let base_dir = absolute_path
.parent()
@@ -137,17 +444,11 @@ impl NixProfileResolver {
let raw_artifact: serde_json::Value =
serde_json::from_slice(&output.stdout).map_err(|source| ProfileError::JsonParse {
path: absolute_path.clone(),
path: absolute_path,
source,
})?;
resolve_profile_artifact(
ProfileSource::Path {
path: absolute_path,
},
&base_dir,
raw_artifact,
)
resolve_profile_artifact(source, &base_dir, raw_artifact)
}
}
@@ -210,6 +511,150 @@ impl ProfileEnvelope {
}
}
#[derive(Debug, Default, Deserialize)]
struct ProfileConfigDocument {
#[serde(default)]
profiles: Option<ProfilesConfig>,
}
#[derive(Debug, Default, Deserialize)]
struct ProfilesConfig {
#[serde(default)]
default: Option<String>,
#[serde(default, alias = "entries")]
profile: BTreeMap<String, ProfileEntryConfig>,
#[serde(default, alias = "aliases")]
alias: BTreeMap<String, String>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum ProfileEntryConfig {
Path(String),
Table {
path: PathBuf,
#[serde(default)]
description: Option<String>,
},
}
impl ProfileEntryConfig {
fn into_parts(self) -> (PathBuf, Option<String>) {
match self {
Self::Path(path) => (PathBuf::from(path), None),
Self::Table { path, description } => (path, description),
}
}
}
fn load_profile_config_manifest(
registry: &mut ProfileRegistry,
source: ProfileRegistrySource,
path: &Path,
) -> Result<(), ProfileError> {
if !path.exists() {
return Ok(());
}
let content = std::fs::read_to_string(path).map_err(|source| ProfileError::ConfigRead {
path: path.to_path_buf(),
source,
})?;
let document: ProfileConfigDocument =
toml::from_str(&content).map_err(|source| ProfileError::ConfigParse {
path: path.to_path_buf(),
source,
})?;
let Some(config) = document.profiles else {
return Ok(());
};
let base = path.parent().unwrap_or_else(|| Path::new("."));
for (name, entry_config) in config.profile {
let (entry_path, description) = entry_config.into_parts();
registry.push_entry(ProfileRegistryEntry {
source,
name,
path: join_if_relative(base, &entry_path),
description,
is_default: false,
});
}
for (name, target) in config.alias {
let (target_source, target_name) = parse_profile_ref(&target);
registry.push_alias(ProfileAlias {
source,
name,
target_source,
target_name,
});
}
if let Some(default) = config.default {
let (default_source, default_name) = parse_profile_ref(&default);
registry.set_default(ProfileDefault {
source: default_source.or(Some(source)),
name: default_name,
});
}
Ok(())
}
fn discover_profile_dir(
registry: &mut ProfileRegistry,
source: ProfileRegistrySource,
dir: &Path,
) -> Result<(), ProfileError> {
if !dir.is_dir() {
return Ok(());
}
for entry in std::fs::read_dir(dir).map_err(|source| ProfileError::ConfigRead {
path: dir.to_path_buf(),
source,
})? {
let entry = entry.map_err(|source| ProfileError::ConfigRead {
path: dir.to_path_buf(),
source,
})?;
let path = entry.path();
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("nix") {
if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
registry.push_entry(ProfileRegistryEntry {
source,
name: name.to_string(),
path,
description: None,
is_default: false,
});
}
} else if path.is_dir() {
let profile = path.join("profile.nix");
if profile.is_file() {
if let Some(name) = path.file_name().and_then(|s| s.to_str()) {
registry.push_entry(ProfileRegistryEntry {
source,
name: name.to_string(),
path: profile,
description: None,
is_default: false,
});
}
}
}
}
Ok(())
}
fn parse_profile_ref(raw: &str) -> (Option<ProfileRegistrySource>, String) {
if let Some((prefix, name)) = raw.split_once(':') {
if let Some(source) = ProfileRegistrySource::parse(prefix) {
return (Some(source), name.to_string());
}
}
(None, raw.to_string())
}
fn extract_manifest_value(raw: &serde_json::Value) -> Result<serde_json::Value, ProfileError> {
match raw {
serde_json::Value::Object(map) => {
@@ -241,7 +686,15 @@ fn absolutize(path: &Path) -> Result<PathBuf, ProfileError> {
}
}
/// Errors raised while evaluating and validating a profile.
fn join_if_relative(base: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
base.join(path)
}
}
/// Errors raised while evaluating, discovering, and validating a profile.
#[derive(Debug, thiserror::Error)]
pub enum ProfileError {
#[error("invalid profile path {}: {message}", .path.display())]
@@ -271,6 +724,29 @@ pub enum ProfileError {
source: serde_json::Error,
},
#[error("failed to read profile registry config {}: {source}", .path.display())]
ConfigRead {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse profile registry config {}: {source}", .path.display())]
ConfigParse {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("no default profile is configured")]
NoDefaultProfile,
#[error("profile not found: {selector}")]
ProfileNotFound { selector: String },
#[error("ambiguous profile name `{name}`; use a source-qualified selector such as {matches:?}")]
AmbiguousProfileName { name: String, matches: Vec<String> },
#[error("failed to decode profile artifact envelope: {source}")]
ArtifactShape {
#[source]
@@ -300,6 +776,7 @@ pub enum ProfileError {
mod tests {
use super::*;
use crate::{AuthRef, Permission, SchemeKind};
use tempfile::TempDir;
fn artifact() -> serde_json::Value {
serde_json::json!({
@@ -425,4 +902,121 @@ mod tests {
assert!(err.to_string().contains("requires the `nix` command"));
assert!(err.to_string().contains("--manifest"));
}
#[test]
fn parse_cli_preserves_paths_and_source_qualified_names() {
assert!(matches!(
ProfileSelector::parse_cli("./coder.nix"),
ProfileSelector::Path { .. }
));
assert_eq!(
ProfileSelector::parse_cli("project:coder"),
ProfileSelector::source_named(ProfileRegistrySource::Project, "coder")
);
assert_eq!(
ProfileSelector::parse_cli("coder"),
ProfileSelector::named("coder")
);
assert_eq!(
ProfileSelector::parse_cli("default"),
ProfileSelector::Default
);
}
#[test]
fn discovery_reads_user_and_project_registry_and_project_default_wins() {
let tmp = TempDir::new().unwrap();
let user_manifest = tmp.path().join("user.toml");
let project_dir = tmp.path().join("project/.insomnia");
std::fs::create_dir_all(&project_dir).unwrap();
let project_manifest = project_dir.join("manifest.toml");
std::fs::write(
&user_manifest,
r#"
[profiles]
default = "coder"
[profiles.profile]
coder = "profiles/user-coder.nix"
"#,
)
.unwrap();
std::fs::write(
&project_manifest,
r#"
[profiles]
default = "project:coder"
[profiles.profile.coder]
path = "profiles/project-coder.nix"
description = "Project coder"
"#,
)
.unwrap();
let registry =
ProfileDiscovery::with_sources(None, Some(user_manifest), Some(project_manifest))
.discover()
.unwrap();
let default = registry.default_entry().unwrap();
assert_eq!(default.source, ProfileRegistrySource::Project);
assert_eq!(default.name, "coder");
assert!(default.path.ends_with("profiles/project-coder.nix"));
}
#[test]
fn unqualified_ambiguous_names_fail_closed() {
let mut registry = ProfileRegistry::default();
registry.push_entry(ProfileRegistryEntry {
source: ProfileRegistrySource::User,
name: "coder".to_string(),
path: PathBuf::from("/user/coder.nix"),
description: None,
is_default: false,
});
registry.push_entry(ProfileRegistryEntry {
source: ProfileRegistrySource::Project,
name: "coder".to_string(),
path: PathBuf::from("/project/coder.nix"),
description: None,
is_default: false,
});
let err = registry
.select(&ProfileSelector::named("coder"))
.unwrap_err();
assert!(matches!(err, ProfileError::AmbiguousProfileName { .. }));
let selected = registry
.select(&ProfileSelector::source_named(
ProfileRegistrySource::Project,
"coder",
))
.unwrap();
assert_eq!(selected.path, PathBuf::from("/project/coder.nix"));
}
#[test]
fn aliases_resolve_within_their_source() {
let mut registry = ProfileRegistry::default();
registry.push_entry(ProfileRegistryEntry {
source: ProfileRegistrySource::Project,
name: "coder".to_string(),
path: PathBuf::from("/project/coder.nix"),
description: None,
is_default: false,
});
registry.push_alias(ProfileAlias {
source: ProfileRegistrySource::Project,
name: "default-coder".to_string(),
target_source: Some(ProfileRegistrySource::Project),
target_name: "coder".to_string(),
});
let selected = registry
.select(&ProfileSelector::source_named(
ProfileRegistrySource::Project,
"default-coder",
))
.unwrap();
assert_eq!(selected.name, "coder");
}
}
+57 -13
View File
@@ -13,14 +13,15 @@ use session_store::{FsStore, PodMetadataStore, SegmentId, Store};
about = "Spawn a Pod process from manifest layers or a single manifest file"
)]
struct Cli {
/// Nix profile to evaluate with `nix eval --json --file <PATH>`.
/// Profiles are resolved artifacts, not manifest-cascade layers.
/// Nix profile to evaluate. Accepts an explicit path, `path:<path>`, a
/// discovered profile name, `default`, or a source-qualified name such as
/// `project:coder`.
#[arg(
long,
value_name = "PATH",
value_name = "PROFILE",
conflicts_with_all = ["manifest", "project", "overlay", "pod", "session", "adopt"]
)]
profile: Option<PathBuf>,
profile: Option<String>,
/// Pod name override for a freshly-created profile Pod. This does not use
/// `--pod` restore semantics, so it must not attach/restore existing Pod
@@ -97,10 +98,11 @@ fn resolve_manifest_with_user_manifest_env_and_profile_loader<F>(
load_profile_fn: F,
) -> Result<(PodManifest, PromptLoader), String>
where
F: FnOnce(&Path, Option<&str>) -> Result<(PodManifest, PromptLoader), String>,
F: FnOnce(&ProfileSelector, Option<&str>) -> Result<(PodManifest, PromptLoader), String>,
{
if let Some(path) = &cli.profile {
return load_profile_fn(path, cli.profile_pod_name.as_deref());
if let Some(profile) = &cli.profile {
let selector = ProfileSelector::parse_cli(profile);
return load_profile_fn(&selector, cli.profile_pod_name.as_deref());
}
let user_manifest = paths::user_manifest_path_from_env(user_manifest_env);
@@ -122,13 +124,16 @@ where
}
fn load_profile(
path: &Path,
selector: &ProfileSelector,
pod_name_override: Option<&str>,
) -> Result<(PodManifest, PromptLoader), String> {
let resolver = NixProfileResolver::new();
let mut resolved = resolver
.resolve(&ProfileSelector::path(path.to_path_buf()))
.map_err(|e| format!("failed to resolve profile {}: {e}", path.display()))?;
let mut resolved = resolver.resolve(selector).map_err(|e| {
format!(
"failed to resolve profile {}: {e}",
selector.display_label()
)
})?;
if let Some(pod_name) = pod_name_override {
resolved.manifest.pod.name = pod_name.to_string();
}
@@ -468,9 +473,9 @@ permission = "write"
let (manifest, loader) = resolve_manifest_with_user_manifest_env_and_profile_loader(
&cli,
Some(OsString::from("non-existent-user-manifest.toml")),
|path, pod_name| {
|selector, pod_name| {
called = true;
assert_eq!(path, profile.as_path());
assert_eq!(selector, &ProfileSelector::path(profile.clone()));
assert_eq!(pod_name, Some("from-profile-name"));
let mut manifest =
PodManifest::from_toml(&manifest_toml("from-profile", tmp.path())).unwrap();
@@ -488,6 +493,45 @@ permission = "write"
assert!(loader.workspace_dir().is_none());
}
#[test]
fn profile_accepts_source_qualified_discovered_name() {
let tmp = TempDir::new().unwrap();
let cli = Cli::try_parse_from([
"insomnia-pod",
"--profile",
"project:coder",
"--profile-pod-name",
"from-profile-name",
])
.unwrap();
let mut called = false;
let (manifest, _loader) = resolve_manifest_with_user_manifest_env_and_profile_loader(
&cli,
None,
|selector, pod_name| {
called = true;
assert_eq!(
selector,
&ProfileSelector::source_named(
manifest::ProfileRegistrySource::Project,
"coder"
)
);
let mut manifest =
PodManifest::from_toml(&manifest_toml("from-profile", tmp.path())).unwrap();
if let Some(pod_name) = pod_name {
manifest.pod.name = pod_name.to_string();
}
Ok((manifest, PromptLoader::builtins_only()))
},
)
.unwrap();
assert!(called);
assert_eq!(manifest.pod.name, "from-profile-name");
}
#[test]
fn manifest_allows_empty_user_manifest_env() {
let tmp = TempDir::new().unwrap();
+11 -11
View File
@@ -61,7 +61,7 @@ fn resolve_socket(pod_name: &str, override_path: Option<PathBuf>) -> PathBuf {
#[derive(Debug)]
enum Mode {
Spawn {
profile_path: Option<PathBuf>,
profile: Option<String>,
},
/// `insomnia <name>` / `insomnia --pod <name>`: attach to a live Pod by name if
/// possible; otherwise launch `insomnia-pod --pod <name>` so the pod process
@@ -113,7 +113,7 @@ where
let mut multi = false;
let mut session: Option<SegmentId> = None;
let mut pod: Option<String> = None;
let mut profile_path: Option<PathBuf> = None;
let mut profile: Option<String> = None;
let mut socket_override: Option<PathBuf> = None;
let mut socket_seen = false;
let mut positional: Option<String> = None;
@@ -148,7 +148,7 @@ where
let raw = args
.get(i + 1)
.ok_or(ParseError::MissingValue("--profile"))?;
profile_path = Some(PathBuf::from(raw));
profile = Some(raw.clone());
i += 2;
}
"--socket" => {
@@ -197,7 +197,7 @@ where
"--multi and --socket are mutually exclusive",
));
}
if profile_path.is_some() {
if profile.is_some() {
return Err(ParseError::Conflict(
"--multi and --profile are mutually exclusive",
));
@@ -220,7 +220,7 @@ where
"--pod and --resume are mutually exclusive",
));
}
if profile_path.is_some()
if profile.is_some()
&& (resume || session.is_some() || pod.is_some() || positional.is_some() || socket_seen)
{
return Err(ParseError::Conflict(
@@ -246,7 +246,7 @@ where
socket_override,
});
}
Ok(Mode::Spawn { profile_path })
Ok(Mode::Spawn { profile })
}
#[tokio::main]
@@ -270,7 +270,7 @@ async fn main() -> ExitCode {
}
let result = match mode {
Mode::Spawn { profile_path } => run_spawn(None, profile_path).await,
Mode::Spawn { profile } => run_spawn(None, profile).await,
Mode::PodName {
pod_name,
socket_override,
@@ -473,9 +473,9 @@ fn is_recoverable_multi_open_error(error: &(dyn std::error::Error + 'static)) ->
async fn run_spawn(
resume_from: Option<SegmentId>,
profile_path: Option<PathBuf>,
profile: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let ready = match spawn::run(resume_from, profile_path).await? {
let ready = match spawn::run(resume_from, profile).await? {
SpawnOutcome::Ready(r) => r,
SpawnOutcome::Cancelled => return Ok(()),
};
@@ -1182,8 +1182,8 @@ mod tests {
#[test]
fn parse_profile_spawn_mode() {
match parse_args_from(["--profile", "/profiles/coder.nix"]).unwrap() {
Mode::Spawn { profile_path } => {
assert_eq!(profile_path, Some(PathBuf::from("/profiles/coder.nix")));
Mode::Spawn { profile } => {
assert_eq!(profile, Some("/profiles/coder.nix".to_string()));
}
_ => panic!("expected Spawn mode"),
}
+68 -16
View File
@@ -20,8 +20,8 @@ use std::time::Duration;
use client::{SpawnConfig, spawn_pod};
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use manifest::{
PodManifestConfig, ScopeConfig, find_project_manifest_from, load_layer, user_manifest_path,
user_manifest_path_from_env,
PodManifestConfig, ProfileDiscovery, ScopeConfig, find_project_manifest_from, load_layer,
user_manifest_path, user_manifest_path_from_env,
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
@@ -93,11 +93,18 @@ type InlineTerminal = Terminal<CrosstermBackend<io::Stdout>>;
/// passes `--session <id>` to the spawned `insomnia-pod` child.
pub async fn run(
resume_from: Option<SegmentId>,
profile_path: Option<PathBuf>,
profile: Option<String>,
) -> Result<SpawnOutcome, SpawnError> {
let defaults = load_spawn_defaults()?;
let scope_origin = match profile_path.as_ref() {
Some(path) => ScopeOrigin::FromProfile(path.clone()),
let selected_profile = profile
.map(|selector| ProfileSelection {
label: selector.clone(),
selector,
is_default: false,
})
.or(defaults.default_profile);
let scope_origin = match selected_profile.as_ref() {
Some(profile) => ScopeOrigin::FromProfile(profile.label.clone()),
None => defaults.scope_origin,
};
@@ -112,7 +119,7 @@ pub async fn run(
resume_from,
resume_by_pod_name: false,
resume_scope: None,
profile_path,
profile: selected_profile,
};
let mut terminal = make_inline_terminal()?;
@@ -212,6 +219,14 @@ struct SpawnDefaults {
cascade_has_scope: bool,
scope_origin: ScopeOrigin,
default_name: String,
default_profile: Option<ProfileSelection>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ProfileSelection {
selector: String,
label: String,
is_default: bool,
}
fn load_spawn_defaults() -> Result<SpawnDefaults, SpawnError> {
@@ -260,11 +275,24 @@ fn load_spawn_defaults() -> Result<SpawnDefaults, SpawnError> {
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "pod".to_string());
let default_profile = default_profile_selection(&cwd);
Ok(SpawnDefaults {
cwd,
cascade_has_scope,
scope_origin,
default_name,
default_profile,
})
}
fn default_profile_selection(cwd: &std::path::Path) -> Option<ProfileSelection> {
let registry = ProfileDiscovery::for_cwd(cwd).discover().ok()?;
let entry = registry.default_entry().ok()?;
Some(ProfileSelection {
selector: entry.qualified_name(),
label: format!("{} (default)", entry.name),
is_default: true,
})
}
@@ -287,7 +315,7 @@ fn form_for_pod_name(pod_name: String, defaults: SpawnDefaults) -> Form {
resume_from: None,
resume_by_pod_name: true,
resume_scope: None,
profile_path: None,
profile: None,
}
}
@@ -361,7 +389,10 @@ async fn wait_for_ready(
let config = SpawnConfig {
pod_name: form.name.clone(),
profile_path: form.profile_path.clone(),
profile: form
.profile
.as_ref()
.map(|profile| profile.selector.clone()),
overlay_toml: overlay_toml.to_string(),
cwd,
resume_from: form.resume_from,
@@ -438,7 +469,7 @@ enum ScopeOrigin {
FromUser,
FromProject,
CwdDefault,
FromProfile(PathBuf),
FromProfile(String),
}
struct Form {
@@ -476,7 +507,7 @@ struct Form {
/// Optional Nix profile passed to `insomnia-pod --profile` for fresh spawns.
/// This is not used for resume/attach flows because those must restore Pod
/// state rather than re-evaluate a profile source.
profile_path: Option<PathBuf>,
profile: Option<ProfileSelection>,
}
impl Form {
@@ -608,13 +639,10 @@ fn context_line(form: &Form) -> Line<'_> {
),
Span::styled(" (write, default)", Style::default().fg(Color::DarkGray)),
]),
ScopeOrigin::FromProfile(ref path) => Line::from(vec![
ScopeOrigin::FromProfile(ref label) => Line::from(vec![
Span::raw(" "),
Span::styled("profile: ", Style::default().fg(Color::DarkGray)),
Span::styled(
path.display().to_string(),
Style::default().fg(Color::Green),
),
Span::styled(label.as_str(), Style::default().fg(Color::Green)),
Span::styled(" (resolved by pod)", Style::default().fg(Color::DarkGray)),
]),
}
@@ -663,7 +691,7 @@ mod tests {
resume_from: None,
resume_by_pod_name: false,
resume_scope: None,
profile_path: None,
profile: None,
}
}
@@ -674,6 +702,7 @@ mod tests {
cascade_has_scope: true,
scope_origin: ScopeOrigin::FromProject,
default_name: "ignored".to_string(),
default_profile: None,
};
let f = form_for_pod_name("agent".to_string(), defaults);
@@ -774,6 +803,29 @@ permission = "write"
);
}
#[test]
fn default_profile_selection_uses_project_registry_default() {
let temp = tempfile::tempdir().unwrap();
let project = temp.path().join("project");
let insomnia = project.join(".insomnia");
std::fs::create_dir_all(&insomnia).unwrap();
std::fs::write(
insomnia.join("manifest.toml"),
r#"
[profiles]
default = "coder"
[profiles.profile]
coder = "profiles/coder.nix"
"#,
)
.unwrap();
let selected = default_profile_selection(&project).unwrap();
assert_eq!(selected.selector, "project:coder");
assert_eq!(selected.label, "coder (default)");
assert!(selected.is_default);
}
#[test]
fn name_input_handles_insert_backspace_and_cursor() {
let mut f = form("", false);