//! Nix profile resolution. //! //! Profiles are a human-authored Nix entrypoint that evaluates to a typed //! 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, 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 { 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: PathBuf }, /// A named profile discovered from builtin/user/project registries. Named { #[serde(default, skip_serializing_if = "Option::is_none")] source: Option, name: String, }, /// The effective default from the discovered profile registry. Default, } impl ProfileSelector { pub fn path(path: impl Into) -> Self { Self::Path { path: path.into() } } pub fn named(name: impl Into) -> Self { Self::Named { source: None, name: name.into(), } } pub fn source_named(source: ProfileRegistrySource, name: impl Into) -> Self { Self::Named { source: Some(source), name: name.into(), } } /// Parse the CLI/TUI `--profile` argument. /// /// `path:` always selects an explicit path. `builtin:`, /// `user:`, and `project:` 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, }, 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, pub is_default: bool, } impl ProfileRegistryEntry { pub fn qualified_name(&self) -> String { format!("{}:{}", self.source, self.name) } } /// Discovered profile registry. User/project `profiles.toml` files contribute /// only profile discovery metadata (entries and defaults); those files are /// application/project UX configuration, not Pod runtime manifests and are not /// merged into the selected profile's runtime manifest. #[derive(Debug, Clone, Default)] pub struct ProfileRegistry { entries: Vec, default: Option, } 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, name: &str, ) -> Result<&ProfileRegistryEntry, ProfileError> { 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 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 Ok(default_entry) = self.select_named(default.source, &default.name) else { return; }; let source = default_entry.source; let name = default_entry.name.clone(); for entry in &mut self.entries { entry.is_default = entry.source == source && entry.name == name; } } } #[derive(Debug, Clone, PartialEq, Eq)] struct ProfileDefault { source: Option, name: String, } /// Filesystem-backed profile discovery. #[derive(Debug, Clone)] pub struct ProfileDiscovery { builtin_dir: Option, user_config: Option, project_config: Option, } impl ProfileDiscovery { pub fn for_cwd(cwd: &Path) -> Self { Self { builtin_dir: paths::builtin_profiles_dir(), user_config: paths::user_profiles_path(), project_config: find_project_profiles_from(cwd), } } pub fn with_sources( builtin_dir: Option, user_config: Option, project_config: Option, ) -> Self { Self { builtin_dir, user_config, project_config, } } pub fn discover(&self) -> Result { 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_config { load_profile_registry_file(&mut registry, ProfileRegistrySource::User, path)?; } if let Some(path) = &self.project_config { load_profile_registry_file(&mut registry, ProfileRegistrySource::Project, path)?; } registry.mark_default_flags(); Ok(registry) } } /// Metadata optionally emitted by `mkProfile`. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct ProfileMetadata { #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub format: Option, } /// Profile provenance embedded in a resolved manifest snapshot. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ProfileManifestSnapshot { pub source: ProfileSource, #[serde(default, skip_serializing_if = "Option::is_none")] pub profile: Option, } /// Validated result of evaluating and resolving a profile. #[derive(Debug, Clone)] pub struct ResolvedProfile { pub source: ProfileSource, pub profile: Option, pub manifest: PodManifest, /// The validated runtime manifest as JSON. This is the snapshot shape future /// Pod restore should prefer over re-evaluating the Nix source. pub manifest_snapshot: serde_json::Value, /// Raw JSON returned by Nix, retained for diagnostics/debugging. pub raw_artifact: serde_json::Value, } /// External-command based Nix resolver. #[derive(Debug, Clone)] pub struct NixProfileResolver { nix_bin: PathBuf, } impl Default for NixProfileResolver { fn default() -> Self { Self { nix_bin: PathBuf::from("nix"), } } } impl NixProfileResolver { pub fn new() -> Self { Self::default() } pub fn with_nix_bin(nix_bin: impl Into) -> Self { Self { nix_bin: nix_bin.into(), } } pub fn resolve(&self, selector: &ProfileSelector) -> Result { match selector { 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, source: ProfileSource, ) -> Result { let absolute_path = absolutize(path)?; let base_dir = absolute_path .parent() .map(Path::to_path_buf) .ok_or_else(|| ProfileError::InvalidPath { path: absolute_path.clone(), message: "profile path has no parent directory".to_string(), })?; let output = Command::new(&self.nix_bin) .arg("eval") .arg("--json") .arg("--file") .arg(&absolute_path) .output() .map_err(|source| { if source.kind() == std::io::ErrorKind::NotFound { ProfileError::NixUnavailable { nix_bin: self.nix_bin.clone(), profile: absolute_path.clone(), } } else { ProfileError::CommandIo { path: absolute_path.clone(), source, } } })?; if !output.status.success() { return Err(ProfileError::NixFailed { path: absolute_path, status: output.status.code(), stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), }); } let raw_artifact: serde_json::Value = serde_json::from_slice(&output.stdout).map_err(|source| ProfileError::JsonParse { path: absolute_path, source, })?; resolve_profile_artifact(source, &base_dir, raw_artifact) } } /// Resolve an already-evaluated profile artifact. Tests and future non-Nix /// resolvers use this to share artifact validation semantics. pub fn resolve_profile_artifact( source: ProfileSource, base_dir: &Path, raw_artifact: serde_json::Value, ) -> Result { if !base_dir.is_absolute() { return Err(ProfileError::InvalidPath { path: base_dir.to_path_buf(), message: "profile base directory must be absolute".to_string(), }); } let envelope: ProfileEnvelope = serde_json::from_value(raw_artifact.clone()) .map_err(|source| ProfileError::ArtifactShape { source })?; envelope.validate_format()?; let manifest_value = extract_manifest_value(&raw_artifact)?; let config: PodManifestConfig = serde_json::from_value(manifest_value.clone()) .map_err(|source| ProfileError::ManifestDeserialize { source })?; let config = PodManifestConfig::builtin_defaults().merge(config.resolve_paths(base_dir)); let mut manifest = PodManifest::try_from(config).map_err(ProfileError::ManifestResolve)?; manifest.profile = Some(ProfileManifestSnapshot { source: source.clone(), profile: envelope.profile.clone(), }); let manifest_snapshot = serde_json::to_value(&manifest).map_err(ProfileError::SnapshotSerialize)?; Ok(ResolvedProfile { source, profile: envelope.profile, manifest, manifest_snapshot, raw_artifact, }) } #[derive(Debug, Deserialize)] struct ProfileEnvelope { #[serde(default)] profile: Option, } impl ProfileEnvelope { fn validate_format(&self) -> Result<(), ProfileError> { let Some(profile) = &self.profile else { return Ok(()); }; match profile.format.as_deref() { None | Some(PROFILE_FORMAT_V1) => Ok(()), Some(found) => Err(ProfileError::UnsupportedFormat { found: found.to_string(), }), } } } #[derive(Debug, Default, Deserialize)] struct ProfileRegistryDocument { #[serde(default)] default: Option, #[serde(default, alias = "entries")] profile: BTreeMap, } #[derive(Debug, Deserialize)] #[serde(untagged)] enum ProfileEntryConfig { Path(String), Table { path: PathBuf, #[serde(default)] description: Option, }, } impl ProfileEntryConfig { fn into_parts(self) -> (PathBuf, Option) { match self { Self::Path(path) => (PathBuf::from(path), None), Self::Table { path, description } => (path, description), } } } fn load_profile_registry_file( 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 config: ProfileRegistryDocument = toml::from_str(&content).map_err(|source| ProfileError::ConfigParse { path: path.to_path_buf(), source, })?; 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, }); } 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(()) } /// Walk up from `start` looking for `.insomnia/profiles.toml`. Returns the /// closest match, or `None` if none is found before reaching the filesystem /// root. fn find_project_profiles_from(start: &Path) -> Option { let start = start .canonicalize() .ok() .unwrap_or_else(|| start.to_path_buf()); let mut cur: Option<&Path> = Some(start.as_path()); while let Some(dir) = cur { let candidate = dir.join(".insomnia").join("profiles.toml"); if candidate.is_file() { return Some(candidate); } cur = dir.parent(); } None } 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, 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 { match raw { serde_json::Value::Object(map) => { let manifest = map.get("manifest"); let config = map.get("config"); match (manifest, config) { (Some(_), Some(_)) => Err(ProfileError::InvalidArtifact( "profile artifact must not contain both `manifest` and `config`".to_string(), )), (Some(value), None) | (None, Some(value)) => Ok(value.clone()), (None, None) => Ok(raw.clone()), } } _ => Err(ProfileError::InvalidArtifact( "profile artifact must be a JSON object".to_string(), )), } } fn absolutize(path: &Path) -> Result { if path.is_absolute() { Ok(path.to_path_buf()) } else { let cwd = std::env::current_dir().map_err(|source| ProfileError::CommandIo { path: PathBuf::from("."), source, })?; Ok(cwd.join(path)) } } 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())] InvalidPath { path: PathBuf, message: String }, #[error("Nix profile resolution requires the `nix` command ({}) but it was not found while resolving {}; install Nix or use --manifest with a resolved TOML manifest", .nix_bin.display(), .profile.display())] NixUnavailable { nix_bin: PathBuf, profile: PathBuf }, #[error("failed to execute nix for profile {}: {source}", .path.display())] CommandIo { path: PathBuf, #[source] source: std::io::Error, }, #[error("nix eval failed for profile {} (status {}): {stderr}", .path.display(), status.map_or_else(|| "signal".to_string(), |s| s.to_string()))] NixFailed { path: PathBuf, status: Option, stderr: String, }, #[error("nix eval did not produce valid JSON for profile {}: {source}", .path.display())] JsonParse { path: PathBuf, #[source] 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 }, #[error("failed to decode profile artifact envelope: {source}")] ArtifactShape { #[source] source: serde_json::Error, }, #[error("unsupported profile artifact format: {found}")] UnsupportedFormat { found: String }, #[error("invalid profile artifact: {0}")] InvalidArtifact(String), #[error("failed to decode profile manifest/config: {source}")] ManifestDeserialize { #[source] source: serde_json::Error, }, #[error("failed to resolve profile manifest/config: {0}")] ManifestResolve(#[source] ResolveError), #[error("failed to serialize resolved manifest snapshot: {0}")] SnapshotSerialize(#[source] serde_json::Error), } #[cfg(test)] mod tests { use super::*; use crate::{AuthRef, Permission, SchemeKind}; use std::sync::{Mutex, MutexGuard, OnceLock}; use tempfile::TempDir; fn env_lock() -> MutexGuard<'static, ()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) .lock() .unwrap_or_else(|e| e.into_inner()) } struct EnvGuard { vars: Vec<(&'static str, Option)>, _lock: MutexGuard<'static, ()>, } impl EnvGuard { fn new(overrides: &[(&'static str, Option<&str>)]) -> Self { let lock = env_lock(); let names = [ "INSOMNIA_CONFIG_DIR", "INSOMNIA_USER_MANIFEST", "INSOMNIA_HOME", "XDG_CONFIG_HOME", "HOME", ]; let saved: Vec<_> = names.iter().map(|n| (*n, std::env::var(n).ok())).collect(); // SAFETY: env_lock() protects environment mutation within this test binary. unsafe { for (n, _) in &saved { std::env::remove_var(n); } for (n, v) in overrides { if let Some(v) = v { std::env::set_var(n, v); } } } Self { vars: saved, _lock: lock, } } } impl Drop for EnvGuard { fn drop(&mut self) { // SAFETY: env_lock() is still held while restoring the environment. unsafe { for (n, v) in &self.vars { match v { Some(v) => std::env::set_var(n, v), None => std::env::remove_var(n), } } } } } fn artifact() -> serde_json::Value { serde_json::json!({ "profile": { "format": "insomnia.nix-profile.v1", "name": "coder", "description": "Coder profile" }, "manifest": { "pod": { "name": "coder-pod" }, "model": { "scheme": "anthropic", "model_id": "claude-sonnet-4-20250514", "auth": { "kind": "secret_ref", "ref": "llm.anthropic.default" } }, "scope": { "allow": [ { "target": ".", "permission": "write" } ] } } }) } #[test] fn resolves_profile_artifact_with_relative_paths() { let resolved = resolve_profile_artifact( ProfileSource::Path { path: PathBuf::from("/profiles/coder.nix"), }, Path::new("/workspace/project"), artifact(), ) .unwrap(); assert_eq!( resolved.profile.as_ref().unwrap().name.as_deref(), Some("coder") ); assert_eq!(resolved.manifest.pod.name, "coder-pod"); assert_eq!(resolved.manifest.model.scheme, Some(SchemeKind::Anthropic)); assert_eq!( resolved.manifest.scope.allow[0].target, PathBuf::from("/workspace/project") ); assert_eq!( resolved.manifest.scope.allow[0].permission, Permission::Write ); assert!(matches!( resolved.manifest.model.auth, Some(AuthRef::SecretRef { ref_ }) if ref_ == "llm.anthropic.default" )); assert_eq!( resolved.manifest_snapshot["model"]["auth"], serde_json::json!({ "kind": "secret_ref", "ref": "llm.anthropic.default" }) ); } #[test] fn rejects_both_manifest_and_config_fields() { let err = resolve_profile_artifact( ProfileSource::Path { path: PathBuf::from("/profiles/bad.nix"), }, Path::new("/workspace/project"), serde_json::json!({ "manifest": {}, "config": {} }), ) .unwrap_err(); assert!(matches!(err, ProfileError::InvalidArtifact(_))); } #[test] fn accepts_raw_manifest_object_for_debug_paths() { let raw = serde_json::json!({ "pod": { "name": "raw" }, "model": { "scheme": "anthropic", "model_id": "claude-sonnet-4-20250514" }, "scope": { "allow": [{ "target": "/tmp/raw", "permission": "read" }] } }); let resolved = resolve_profile_artifact( ProfileSource::Path { path: PathBuf::from("/profiles/raw.nix"), }, Path::new("/profiles"), raw, ) .unwrap(); assert_eq!(resolved.manifest.pod.name, "raw"); assert_eq!( resolved.manifest.scope.allow[0].target, PathBuf::from("/tmp/raw") ); } #[test] fn rejects_unknown_profile_format() { let mut raw = artifact(); raw["profile"]["format"] = serde_json::json!("insomnia.nix-profile.v99"); let err = resolve_profile_artifact( ProfileSource::Path { path: PathBuf::from("/profiles/coder.nix"), }, Path::new("/workspace/project"), raw, ) .unwrap_err(); assert!(matches!(err, ProfileError::UnsupportedFormat { .. })); } #[test] fn missing_nix_has_clear_diagnostic() { let resolver = NixProfileResolver::with_nix_bin("/definitely/missing/nix"); let err = resolver .resolve(&ProfileSelector::path("/profiles/coder.nix")) .unwrap_err(); assert!(matches!(err, ProfileError::NixUnavailable { .. })); 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 for_cwd_reads_profiles_toml_and_ignores_manifest_profiles() { let tmp = TempDir::new().unwrap(); let config_dir = tmp.path().join("config"); std::fs::create_dir_all(&config_dir).unwrap(); let _env = EnvGuard::new(&[("INSOMNIA_CONFIG_DIR", Some(config_dir.to_str().unwrap()))]); let project = tmp.path().join("project").join("nested"); let insomnia = tmp.path().join("project").join(".insomnia"); std::fs::create_dir_all(&project).unwrap(); std::fs::create_dir_all(&insomnia).unwrap(); std::fs::write( insomnia.join("manifest.toml"), r#" [profiles] default = "wrong" [profiles.profile] wrong = "wrong.nix" "#, ) .unwrap(); std::fs::write( insomnia.join("profiles.toml"), r#" default = "coder" [profile] coder = "profiles/coder.nix" "#, ) .unwrap(); let registry = ProfileDiscovery::for_cwd(&project).discover().unwrap(); assert!(registry.select_named(None, "wrong").is_err()); let selected = registry.default_entry().unwrap(); assert_eq!(selected.source, ProfileRegistrySource::Project); assert_eq!(selected.name, "coder"); } #[test] fn user_manifest_env_does_not_affect_profile_registry_discovery() { let tmp = TempDir::new().unwrap(); let config_dir = tmp.path().join("config"); std::fs::create_dir_all(&config_dir).unwrap(); let env_manifest = tmp.path().join("env-manifest.toml"); std::fs::write( &env_manifest, r#" [profiles] default = "wrong" [profiles.profile] wrong = "wrong.nix" "#, ) .unwrap(); std::fs::write( config_dir.join("profiles.toml"), r#" default = "coder" [profile] coder = "profiles/coder.nix" "#, ) .unwrap(); let _env = EnvGuard::new(&[ ("INSOMNIA_CONFIG_DIR", Some(config_dir.to_str().unwrap())), ( "INSOMNIA_USER_MANIFEST", Some(env_manifest.to_str().unwrap()), ), ]); let registry = ProfileDiscovery::for_cwd(tmp.path()).discover().unwrap(); assert!(registry.select_named(None, "wrong").is_err()); let selected = registry.default_entry().unwrap(); assert_eq!(selected.source, ProfileRegistrySource::User); assert_eq!(selected.name, "coder"); } #[test] fn discovery_reads_user_and_project_registry_and_project_default_wins() { let tmp = TempDir::new().unwrap(); let user_config = tmp.path().join("profiles.toml"); let project_dir = tmp.path().join("project/.insomnia"); std::fs::create_dir_all(&project_dir).unwrap(); let project_config = project_dir.join("profiles.toml"); std::fs::write( &user_config, r#" default = "coder" [profile] coder = "profiles/user-coder.nix" "#, ) .unwrap(); std::fs::write( &project_config, r#" default = "project:coder" [profile.coder] path = "profiles/project-coder.nix" description = "Project coder" "#, ) .unwrap(); let registry = ProfileDiscovery::with_sources(None, Some(user_config), Some(project_config)) .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 default_marks_direct_profile_entry() { let tmp = TempDir::new().unwrap(); let project_dir = tmp.path().join("project/.insomnia"); std::fs::create_dir_all(&project_dir).unwrap(); let project_config = project_dir.join("profiles.toml"); std::fs::write( &project_config, r#" default = "coder" [profile] coder = "profiles/coder.nix" "#, ) .unwrap(); let registry = ProfileDiscovery::with_sources(None, None, Some(project_config)) .discover() .unwrap(); let default = registry.default_entry().unwrap(); assert_eq!(default.source, ProfileRegistrySource::Project); assert_eq!(default.name, "coder"); assert!(default.is_default); assert_eq!( registry .entries() .iter() .filter(|entry| entry.is_default) .count(), 1 ); } #[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")); } }