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
+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");
}
}