feat: add manifest profile discovery

This commit is contained in:
Keisuke Hirata 2026-05-30 02:18:42 +09:00
parent 06c778a725
commit ee7147b355
No known key found for this signature in database
8 changed files with 822 additions and 63 deletions

View File

@ -27,11 +27,11 @@ pub struct SpawnConfig {
/// (`manifest::paths::pod_runtime_dir`) の解決と、ready 行に乗る /// (`manifest::paths::pod_runtime_dir`) の解決と、ready 行に乗る
/// 名前との突き合わせに使う。 /// 名前との突き合わせに使う。
pub pod_name: String, 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 /// `--profile` and the TOML overlay is not passed; the Pod name is supplied
/// through `--profile-pod-name` so profile evaluation stays separate from /// through `--profile-pod-name` so profile evaluation stays separate from
/// manifest layer merging and from `--pod` restore semantics. /// manifest layer merging and from `--pod` restore semantics.
pub profile_path: Option<PathBuf>, pub profile: Option<String>,
/// `--overlay` で pod に渡す TOML 文字列。 /// `--overlay` で pod に渡す TOML 文字列。
pub overlay_toml: String, pub overlay_toml: String,
/// pod の current_dir。 /// pod の current_dir。
@ -117,16 +117,16 @@ where
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::from(stderr_file)) .stderr(Stdio::from(stderr_file))
.process_group(0); .process_group(0);
if let Some(profile_path) = &config.profile_path { if let Some(profile) = &config.profile {
command command
.arg("--profile") .arg("--profile")
.arg(profile_path) .arg(profile)
.arg("--profile-pod-name") .arg("--profile-pod-name")
.arg(&config.pod_name); .arg(&config.pod_name);
} else { } else {
command.arg("--overlay").arg(&config.overlay_toml); 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); command.arg("--pod").arg(&config.pod_name);
} }
if let Some(id) = config.resume_from { if let Some(id) = config.resume_from {

View File

@ -18,8 +18,9 @@ pub use paths::{
user_manifest_path, user_manifest_path_from_env, user_manifest_path_with_env_override, user_manifest_path, user_manifest_path_from_env, user_manifest_path_with_env_override,
}; };
pub use profile::{ pub use profile::{
NixProfileResolver, ProfileError, ProfileManifestSnapshot, ProfileMetadata, ProfileSelector, NixProfileResolver, ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata,
ProfileSource, ResolvedProfile, resolve_profile_artifact, ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource, ProfileSelector, ProfileSource,
ResolvedProfile, resolve_profile_artifact,
}; };
pub use protocol::{Permission, ScopeRule}; pub use protocol::{Permission, ScopeRule};
pub use scope::{Scope, ScopeError, SharedScope}; pub use scope::{Scope, ScopeError, SharedScope};

View File

@ -33,6 +33,9 @@ use std::path::PathBuf;
/// auto-discovered user manifest path. /// auto-discovered user manifest path.
pub const USER_MANIFEST_ENV: &str = "INSOMNIA_USER_MANIFEST"; 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`, /// 設定ディレクトリ。`manifest.toml`, `providers.toml`, `models.toml`,
/// `prompts/` などが置かれる。 /// `prompts/` などが置かれる。
pub fn config_dir() -> Option<PathBuf> { pub fn config_dir() -> Option<PathBuf> {
@ -114,6 +117,32 @@ pub fn user_prompts_dir() -> Option<PathBuf> {
Some(config_dir()?.join("prompts")) 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。 /// `<config_dir>/prompts.toml` — user prompt pack。
pub fn user_pack_file() -> Option<PathBuf> { pub fn user_pack_file() -> Option<PathBuf> {
Some(config_dir()?.join("prompts.toml")) Some(config_dir()?.join("prompts.toml"))
@ -192,6 +221,7 @@ mod tests {
"INSOMNIA_DATA_DIR", "INSOMNIA_DATA_DIR",
"INSOMNIA_RUNTIME_DIR", "INSOMNIA_RUNTIME_DIR",
"INSOMNIA_USER_MANIFEST", "INSOMNIA_USER_MANIFEST",
"INSOMNIA_RESOURCE_DIR",
"INSOMNIA_HOME", "INSOMNIA_HOME",
"XDG_CONFIG_HOME", "XDG_CONFIG_HOME",
"XDG_RUNTIME_DIR", "XDG_RUNTIME_DIR",

View File

@ -4,34 +4,316 @@
//! resolved artifact. Rust consumes the evaluated JSON artifact directly and //! resolved artifact. Rust consumes the evaluated JSON artifact directly and
//! validates it into the existing [`crate::PodManifest`] runtime contract. //! validates it into the existing [`crate::PodManifest`] runtime contract.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{PodManifest, PodManifestConfig, ResolveError}; use crate::{PodManifest, PodManifestConfig, ResolveError, paths};
const PROFILE_FORMAT_V1: &str = "insomnia.nix-profile.v1"; 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. /// User selection of a profile source.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")] #[serde(tag = "kind", rename_all = "snake_case")]
pub enum ProfileSelector { pub enum ProfileSelector {
/// A local Nix expression evaluated with `nix eval --json --file <path>`. /// A local Nix expression evaluated with `nix eval --json --file <path>`.
Path { path: PathBuf }, 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 { impl ProfileSelector {
pub fn path(path: impl Into<PathBuf>) -> Self { pub fn path(path: impl Into<PathBuf>) -> Self {
Self::Path { path: path.into() } 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. /// Profile source recorded with a resolved artifact.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")] #[serde(tag = "kind", rename_all = "snake_case")]
pub enum ProfileSource { 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`. /// Metadata optionally emitted by `mkProfile`.
@ -93,11 +375,36 @@ impl NixProfileResolver {
pub fn resolve(&self, selector: &ProfileSelector) -> Result<ResolvedProfile, ProfileError> { pub fn resolve(&self, selector: &ProfileSelector) -> Result<ResolvedProfile, ProfileError> {
match selector { 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 absolute_path = absolutize(path)?;
let base_dir = absolute_path let base_dir = absolute_path
.parent() .parent()
@ -137,17 +444,11 @@ impl NixProfileResolver {
let raw_artifact: serde_json::Value = let raw_artifact: serde_json::Value =
serde_json::from_slice(&output.stdout).map_err(|source| ProfileError::JsonParse { serde_json::from_slice(&output.stdout).map_err(|source| ProfileError::JsonParse {
path: absolute_path.clone(), path: absolute_path,
source, source,
})?; })?;
resolve_profile_artifact( resolve_profile_artifact(source, &base_dir, raw_artifact)
ProfileSource::Path {
path: absolute_path,
},
&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> { fn extract_manifest_value(raw: &serde_json::Value) -> Result<serde_json::Value, ProfileError> {
match raw { match raw {
serde_json::Value::Object(map) => { 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)] #[derive(Debug, thiserror::Error)]
pub enum ProfileError { pub enum ProfileError {
#[error("invalid profile path {}: {message}", .path.display())] #[error("invalid profile path {}: {message}", .path.display())]
@ -271,6 +724,29 @@ pub enum ProfileError {
source: serde_json::Error, 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}")] #[error("failed to decode profile artifact envelope: {source}")]
ArtifactShape { ArtifactShape {
#[source] #[source]
@ -300,6 +776,7 @@ pub enum ProfileError {
mod tests { mod tests {
use super::*; use super::*;
use crate::{AuthRef, Permission, SchemeKind}; use crate::{AuthRef, Permission, SchemeKind};
use tempfile::TempDir;
fn artifact() -> serde_json::Value { fn artifact() -> serde_json::Value {
serde_json::json!({ serde_json::json!({
@ -425,4 +902,121 @@ mod tests {
assert!(err.to_string().contains("requires the `nix` command")); assert!(err.to_string().contains("requires the `nix` command"));
assert!(err.to_string().contains("--manifest")); 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");
}
} }

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" about = "Spawn a Pod process from manifest layers or a single manifest file"
)] )]
struct Cli { struct Cli {
/// Nix profile to evaluate with `nix eval --json --file <PATH>`. /// Nix profile to evaluate. Accepts an explicit path, `path:<path>`, a
/// Profiles are resolved artifacts, not manifest-cascade layers. /// discovered profile name, `default`, or a source-qualified name such as
/// `project:coder`.
#[arg( #[arg(
long, long,
value_name = "PATH", value_name = "PROFILE",
conflicts_with_all = ["manifest", "project", "overlay", "pod", "session", "adopt"] 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 name override for a freshly-created profile Pod. This does not use
/// `--pod` restore semantics, so it must not attach/restore existing Pod /// `--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, load_profile_fn: F,
) -> Result<(PodManifest, PromptLoader), String> ) -> Result<(PodManifest, PromptLoader), String>
where 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 { if let Some(profile) = &cli.profile {
return load_profile_fn(path, cli.profile_pod_name.as_deref()); 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); let user_manifest = paths::user_manifest_path_from_env(user_manifest_env);
@ -122,13 +124,16 @@ where
} }
fn load_profile( fn load_profile(
path: &Path, selector: &ProfileSelector,
pod_name_override: Option<&str>, pod_name_override: Option<&str>,
) -> Result<(PodManifest, PromptLoader), String> { ) -> Result<(PodManifest, PromptLoader), String> {
let resolver = NixProfileResolver::new(); let resolver = NixProfileResolver::new();
let mut resolved = resolver let mut resolved = resolver.resolve(selector).map_err(|e| {
.resolve(&ProfileSelector::path(path.to_path_buf())) format!(
.map_err(|e| format!("failed to resolve profile {}: {e}", path.display()))?; "failed to resolve profile {}: {e}",
selector.display_label()
)
})?;
if let Some(pod_name) = pod_name_override { if let Some(pod_name) = pod_name_override {
resolved.manifest.pod.name = pod_name.to_string(); 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( let (manifest, loader) = resolve_manifest_with_user_manifest_env_and_profile_loader(
&cli, &cli,
Some(OsString::from("non-existent-user-manifest.toml")), Some(OsString::from("non-existent-user-manifest.toml")),
|path, pod_name| { |selector, pod_name| {
called = true; called = true;
assert_eq!(path, profile.as_path()); assert_eq!(selector, &ProfileSelector::path(profile.clone()));
assert_eq!(pod_name, Some("from-profile-name")); assert_eq!(pod_name, Some("from-profile-name"));
let mut manifest = let mut manifest =
PodManifest::from_toml(&manifest_toml("from-profile", tmp.path())).unwrap(); PodManifest::from_toml(&manifest_toml("from-profile", tmp.path())).unwrap();
@ -488,6 +493,45 @@ permission = "write"
assert!(loader.workspace_dir().is_none()); 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] #[test]
fn manifest_allows_empty_user_manifest_env() { fn manifest_allows_empty_user_manifest_env() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();

View File

@ -61,7 +61,7 @@ fn resolve_socket(pod_name: &str, override_path: Option<PathBuf>) -> PathBuf {
#[derive(Debug)] #[derive(Debug)]
enum Mode { enum Mode {
Spawn { Spawn {
profile_path: Option<PathBuf>, profile: Option<String>,
}, },
/// `insomnia <name>` / `insomnia --pod <name>`: attach to a live Pod by name if /// `insomnia <name>` / `insomnia --pod <name>`: attach to a live Pod by name if
/// possible; otherwise launch `insomnia-pod --pod <name>` so the pod process /// possible; otherwise launch `insomnia-pod --pod <name>` so the pod process
@ -113,7 +113,7 @@ where
let mut multi = false; let mut multi = false;
let mut session: Option<SegmentId> = None; let mut session: Option<SegmentId> = None;
let mut pod: Option<String> = 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_override: Option<PathBuf> = None;
let mut socket_seen = false; let mut socket_seen = false;
let mut positional: Option<String> = None; let mut positional: Option<String> = None;
@ -148,7 +148,7 @@ where
let raw = args let raw = args
.get(i + 1) .get(i + 1)
.ok_or(ParseError::MissingValue("--profile"))?; .ok_or(ParseError::MissingValue("--profile"))?;
profile_path = Some(PathBuf::from(raw)); profile = Some(raw.clone());
i += 2; i += 2;
} }
"--socket" => { "--socket" => {
@ -197,7 +197,7 @@ where
"--multi and --socket are mutually exclusive", "--multi and --socket are mutually exclusive",
)); ));
} }
if profile_path.is_some() { if profile.is_some() {
return Err(ParseError::Conflict( return Err(ParseError::Conflict(
"--multi and --profile are mutually exclusive", "--multi and --profile are mutually exclusive",
)); ));
@ -220,7 +220,7 @@ where
"--pod and --resume are mutually exclusive", "--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) && (resume || session.is_some() || pod.is_some() || positional.is_some() || socket_seen)
{ {
return Err(ParseError::Conflict( return Err(ParseError::Conflict(
@ -246,7 +246,7 @@ where
socket_override, socket_override,
}); });
} }
Ok(Mode::Spawn { profile_path }) Ok(Mode::Spawn { profile })
} }
#[tokio::main] #[tokio::main]
@ -270,7 +270,7 @@ async fn main() -> ExitCode {
} }
let result = match mode { let result = match mode {
Mode::Spawn { profile_path } => run_spawn(None, profile_path).await, Mode::Spawn { profile } => run_spawn(None, profile).await,
Mode::PodName { Mode::PodName {
pod_name, pod_name,
socket_override, socket_override,
@ -473,9 +473,9 @@ fn is_recoverable_multi_open_error(error: &(dyn std::error::Error + 'static)) ->
async fn run_spawn( async fn run_spawn(
resume_from: Option<SegmentId>, resume_from: Option<SegmentId>,
profile_path: Option<PathBuf>, profile: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> 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::Ready(r) => r,
SpawnOutcome::Cancelled => return Ok(()), SpawnOutcome::Cancelled => return Ok(()),
}; };
@ -1182,8 +1182,8 @@ mod tests {
#[test] #[test]
fn parse_profile_spawn_mode() { fn parse_profile_spawn_mode() {
match parse_args_from(["--profile", "/profiles/coder.nix"]).unwrap() { match parse_args_from(["--profile", "/profiles/coder.nix"]).unwrap() {
Mode::Spawn { profile_path } => { Mode::Spawn { profile } => {
assert_eq!(profile_path, Some(PathBuf::from("/profiles/coder.nix"))); assert_eq!(profile, Some("/profiles/coder.nix".to_string()));
} }
_ => panic!("expected Spawn mode"), _ => panic!("expected Spawn mode"),
} }

View File

@ -20,8 +20,8 @@ use std::time::Duration;
use client::{SpawnConfig, spawn_pod}; use client::{SpawnConfig, spawn_pod};
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers}; use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use manifest::{ use manifest::{
PodManifestConfig, ScopeConfig, find_project_manifest_from, load_layer, user_manifest_path, PodManifestConfig, ProfileDiscovery, ScopeConfig, find_project_manifest_from, load_layer,
user_manifest_path_from_env, user_manifest_path, user_manifest_path_from_env,
}; };
use ratatui::Terminal; use ratatui::Terminal;
use ratatui::backend::CrosstermBackend; use ratatui::backend::CrosstermBackend;
@ -93,11 +93,18 @@ type InlineTerminal = Terminal<CrosstermBackend<io::Stdout>>;
/// passes `--session <id>` to the spawned `insomnia-pod` child. /// passes `--session <id>` to the spawned `insomnia-pod` child.
pub async fn run( pub async fn run(
resume_from: Option<SegmentId>, resume_from: Option<SegmentId>,
profile_path: Option<PathBuf>, profile: Option<String>,
) -> Result<SpawnOutcome, SpawnError> { ) -> Result<SpawnOutcome, SpawnError> {
let defaults = load_spawn_defaults()?; let defaults = load_spawn_defaults()?;
let scope_origin = match profile_path.as_ref() { let selected_profile = profile
Some(path) => ScopeOrigin::FromProfile(path.clone()), .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, None => defaults.scope_origin,
}; };
@ -112,7 +119,7 @@ pub async fn run(
resume_from, resume_from,
resume_by_pod_name: false, resume_by_pod_name: false,
resume_scope: None, resume_scope: None,
profile_path, profile: selected_profile,
}; };
let mut terminal = make_inline_terminal()?; let mut terminal = make_inline_terminal()?;
@ -212,6 +219,14 @@ struct SpawnDefaults {
cascade_has_scope: bool, cascade_has_scope: bool,
scope_origin: ScopeOrigin, scope_origin: ScopeOrigin,
default_name: String, 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> { fn load_spawn_defaults() -> Result<SpawnDefaults, SpawnError> {
@ -260,11 +275,24 @@ fn load_spawn_defaults() -> Result<SpawnDefaults, SpawnError> {
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.unwrap_or_else(|| "pod".to_string()); .unwrap_or_else(|| "pod".to_string());
let default_profile = default_profile_selection(&cwd);
Ok(SpawnDefaults { Ok(SpawnDefaults {
cwd, cwd,
cascade_has_scope, cascade_has_scope,
scope_origin, scope_origin,
default_name, 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_from: None,
resume_by_pod_name: true, resume_by_pod_name: true,
resume_scope: None, resume_scope: None,
profile_path: None, profile: None,
} }
} }
@ -361,7 +389,10 @@ async fn wait_for_ready(
let config = SpawnConfig { let config = SpawnConfig {
pod_name: form.name.clone(), 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(), overlay_toml: overlay_toml.to_string(),
cwd, cwd,
resume_from: form.resume_from, resume_from: form.resume_from,
@ -438,7 +469,7 @@ enum ScopeOrigin {
FromUser, FromUser,
FromProject, FromProject,
CwdDefault, CwdDefault,
FromProfile(PathBuf), FromProfile(String),
} }
struct Form { struct Form {
@ -476,7 +507,7 @@ struct Form {
/// Optional Nix profile passed to `insomnia-pod --profile` for fresh spawns. /// Optional Nix profile passed to `insomnia-pod --profile` for fresh spawns.
/// This is not used for resume/attach flows because those must restore Pod /// This is not used for resume/attach flows because those must restore Pod
/// state rather than re-evaluate a profile source. /// state rather than re-evaluate a profile source.
profile_path: Option<PathBuf>, profile: Option<ProfileSelection>,
} }
impl Form { impl Form {
@ -608,13 +639,10 @@ fn context_line(form: &Form) -> Line<'_> {
), ),
Span::styled(" (write, default)", Style::default().fg(Color::DarkGray)), 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::raw(" "),
Span::styled("profile: ", Style::default().fg(Color::DarkGray)), Span::styled("profile: ", Style::default().fg(Color::DarkGray)),
Span::styled( Span::styled(label.as_str(), Style::default().fg(Color::Green)),
path.display().to_string(),
Style::default().fg(Color::Green),
),
Span::styled(" (resolved by pod)", Style::default().fg(Color::DarkGray)), Span::styled(" (resolved by pod)", Style::default().fg(Color::DarkGray)),
]), ]),
} }
@ -663,7 +691,7 @@ mod tests {
resume_from: None, resume_from: None,
resume_by_pod_name: false, resume_by_pod_name: false,
resume_scope: None, resume_scope: None,
profile_path: None, profile: None,
} }
} }
@ -674,6 +702,7 @@ mod tests {
cascade_has_scope: true, cascade_has_scope: true,
scope_origin: ScopeOrigin::FromProject, scope_origin: ScopeOrigin::FromProject,
default_name: "ignored".to_string(), default_name: "ignored".to_string(),
default_profile: None,
}; };
let f = form_for_pod_name("agent".to_string(), defaults); 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] #[test]
fn name_input_handles_insert_backspace_and_cursor() { fn name_input_handles_insert_backspace_and_cursor() {
let mut f = form("", false); let mut f = form("", false);

View File

@ -2,7 +2,7 @@
Manifest profiles are the human-authored Nix entrypoint for generating an Insomnia runtime manifest. The Rust side evaluates a selected profile with `nix eval --json --file <path>`, deserializes the resulting JSON artifact, and validates it through the existing `PodManifest` pipeline. Manifest profiles are the human-authored Nix entrypoint for generating an Insomnia runtime manifest. The Rust side evaluates a selected profile with `nix eval --json --file <path>`, deserializes the resulting JSON artifact, and validates it through the existing `PodManifest` pipeline.
This keeps composition/import/common logic in Nix. Insomnia does not add an implicit profile cascade or merge TOML profile layers at runtime. This keeps composition/import/common logic in Nix. Insomnia does not add an implicit profile cascade or merge TOML profile layers into the selected runtime manifest.
## Minimal profile ## Minimal profile
@ -27,7 +27,7 @@ insomnia.mkProfile {
} }
``` ```
Run it with: Run an explicit path with:
```sh ```sh
insomnia-pod --profile ./coder.nix insomnia-pod --profile ./coder.nix
@ -35,7 +35,45 @@ insomnia-pod --profile ./coder.nix
insomnia --profile ./coder.nix insomnia --profile ./coder.nix
``` ```
`--profile` conflicts with `insomnia-pod --manifest` and with restore/session/adopt modes. Use `--profile-pod-name <name>` when a launcher needs a creation-time Pod name override without invoking `--pod` restore semantics. Profile evaluation is a creation-time path; Pod resume should restore saved Pod state/resolved snapshots rather than re-evaluating the Nix source. `--profile` accepts an explicit path, `path:<path>`, a discovered profile name, `default`, or a source-qualified name such as `project:coder`, `user:coder`, or `builtin:coder`. Path-like values containing `/`, starting with `.`, or ending in `.nix` preserve the original explicit-path behavior.
`--profile` conflicts with `insomnia-pod --manifest` and with restore/session/adopt modes. Use `--profile-pod-name <name>` when a launcher needs a creation-time Pod name override without invoking `--pod` restore semantics. Profile evaluation is a creation-time path; Pod resume restores saved Pod state/resolved snapshots rather than re-evaluating the Nix source.
## Profile discovery
Profile discovery is separate from runtime manifest merging. User/project TOML files may declare profile registry metadata under `[profiles]`, but those files are not merged into the Nix profile artifact.
Example project config at `.insomnia/manifest.toml`:
```toml
[profiles]
default = "coder"
[profiles.profile]
coder = "profiles/coder.nix"
reviewer = "profiles/reviewer.nix"
[profiles.alias]
work = "project:coder"
```
Table entries can carry descriptions:
```toml
[profiles.profile.coder]
path = "profiles/coder.nix"
description = "Project coding assistant"
```
Relative registry paths are resolved against the TOML file that declares them. Discovery checks builtin profiles, then the user manifest, then the nearest project manifest. Later defaults override earlier defaults, so a project default wins over a user default. Unqualified ambiguous names fail closed:
```sh
insomnia --profile coder # fails if both user:coder and project:coder exist
insomnia --profile project:coder # source-qualified selection
insomnia --profile default # selected registry default
```
The fresh-spawn TUI also uses discovery. If a default profile is configured, the new Pod dialog shows `profile: coder (default)` and spawns with the source-qualified selector. Passing `insomnia --profile <selector>` opens the same new Pod dialog with that selector shown and leaves Pod-name editing unchanged.
## Artifact contract ## Artifact contract