fix: move profile registry to profiles config

This commit is contained in:
Keisuke Hirata 2026-05-30 03:10:46 +09:00
parent 513f55415f
commit 3f67c83d43
No known key found for this signature in database
5 changed files with 220 additions and 67 deletions

View File

@ -16,6 +16,7 @@ pub use model::{
};
pub use paths::{
user_manifest_path, user_manifest_path_from_env, user_manifest_path_with_env_override,
user_profiles_path,
};
pub use profile::{
NixProfileResolver, ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata,

View File

@ -112,6 +112,14 @@ pub fn user_manifest_path_with_env_override() -> Option<PathBuf> {
user_manifest_path_from_env(std::env::var_os(USER_MANIFEST_ENV)).or_else(user_manifest_path)
}
/// `<config_dir>/profiles.toml` — user profile registry/default/alias configuration.
///
/// This is application/profile selection configuration, not a Pod manifest
/// layer. It deliberately ignores [`USER_MANIFEST_ENV`].
pub fn user_profiles_path() -> Option<PathBuf> {
Some(config_dir()?.join("profiles.toml"))
}
/// `<config_dir>/prompts/` — user prompts ライブラリ。
pub fn user_prompts_dir() -> Option<PathBuf> {
Some(config_dir()?.join("prompts"))
@ -385,6 +393,10 @@ mod tests {
user_manifest_path().unwrap(),
PathBuf::from("/sand/config/manifest.toml")
);
assert_eq!(
user_profiles_path().unwrap(),
PathBuf::from("/sand/config/profiles.toml")
);
assert_eq!(
user_prompts_dir().unwrap(),
PathBuf::from("/sand/config/prompts")

View File

@ -152,9 +152,10 @@ impl ProfileRegistryEntry {
}
}
/// 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.
/// Discovered profile registry. User/project `profiles.toml` files contribute
/// only profile discovery metadata (entries, aliases, 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<ProfileRegistryEntry>,
@ -277,28 +278,28 @@ struct ProfileDefault {
#[derive(Debug, Clone)]
pub struct ProfileDiscovery {
builtin_dir: Option<PathBuf>,
user_manifest: Option<PathBuf>,
project_manifest: Option<PathBuf>,
user_config: Option<PathBuf>,
project_config: 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),
user_config: paths::user_profiles_path(),
project_config: find_project_profiles_from(cwd),
}
}
pub fn with_sources(
builtin_dir: Option<PathBuf>,
user_manifest: Option<PathBuf>,
project_manifest: Option<PathBuf>,
user_config: Option<PathBuf>,
project_config: Option<PathBuf>,
) -> Self {
Self {
builtin_dir,
user_manifest,
project_manifest,
user_config,
project_config,
}
}
@ -307,11 +308,11 @@ impl ProfileDiscovery {
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.user_config {
load_profile_registry_file(&mut registry, ProfileRegistrySource::User, path)?;
}
if let Some(path) = &self.project_manifest {
load_profile_config_manifest(&mut registry, ProfileRegistrySource::Project, path)?;
if let Some(path) = &self.project_config {
load_profile_registry_file(&mut registry, ProfileRegistrySource::Project, path)?;
}
registry.mark_default_flags();
Ok(registry)
@ -514,13 +515,7 @@ impl ProfileEnvelope {
}
#[derive(Debug, Default, Deserialize)]
struct ProfileConfigDocument {
#[serde(default)]
profiles: Option<ProfilesConfig>,
}
#[derive(Debug, Default, Deserialize)]
struct ProfilesConfig {
struct ProfileRegistryDocument {
#[serde(default)]
default: Option<String>,
#[serde(default, alias = "entries")]
@ -549,7 +544,7 @@ impl ProfileEntryConfig {
}
}
fn load_profile_config_manifest(
fn load_profile_registry_file(
registry: &mut ProfileRegistry,
source: ProfileRegistrySource,
path: &Path,
@ -561,14 +556,11 @@ fn load_profile_config_manifest(
path: path.to_path_buf(),
source,
})?;
let document: ProfileConfigDocument =
let config: ProfileRegistryDocument =
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 {
@ -603,6 +595,25 @@ fn load_profile_config_manifest(
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<PathBuf> {
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,
@ -778,8 +789,64 @@ pub enum ProfileError {
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<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|e| e.into_inner())
}
struct EnvGuard {
vars: Vec<(&'static str, Option<String>)>,
_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": {
@ -926,28 +993,104 @@ mod tests {
}
#[test]
fn discovery_reads_user_and_project_registry_and_project_default_wins() {
fn for_cwd_reads_profiles_toml_and_ignores_manifest_profiles() {
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");
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(
&user_manifest,
insomnia.join("manifest.toml"),
r#"
[profiles]
default = "coder"
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_manifest,
&project_config,
r#"
[profiles]
default = "project:coder"
[profiles.profile.coder]
[profile.coder]
path = "profiles/project-coder.nix"
description = "Project coder"
"#,
@ -955,7 +1098,7 @@ description = "Project coder"
.unwrap();
let registry =
ProfileDiscovery::with_sources(None, Some(user_manifest), Some(project_manifest))
ProfileDiscovery::with_sources(None, Some(user_config), Some(project_config))
.discover()
.unwrap();
@ -968,31 +1111,31 @@ description = "Project coder"
#[test]
fn config_alias_unqualified_target_resolves_within_declaring_source() {
let tmp = TempDir::new().unwrap();
let user_manifest = tmp.path().join("user.toml");
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_manifest = project_dir.join("manifest.toml");
let project_config = project_dir.join("profiles.toml");
std::fs::write(
&user_manifest,
&user_config,
r#"
[profiles.profile]
[profile]
coder = "profiles/user-coder.nix"
"#,
)
.unwrap();
std::fs::write(
&project_manifest,
&project_config,
r#"
[profiles.profile]
[profile]
coder = "profiles/project-coder.nix"
[profiles.alias]
[alias]
default-coder = "coder"
"#,
)
.unwrap();
let registry =
ProfileDiscovery::with_sources(None, Some(user_manifest), Some(project_manifest))
ProfileDiscovery::with_sources(None, Some(user_config), Some(project_config))
.discover()
.unwrap();
let selected = registry
@ -1012,21 +1155,20 @@ default-coder = "coder"
let tmp = TempDir::new().unwrap();
let project_dir = tmp.path().join("project/.insomnia");
std::fs::create_dir_all(&project_dir).unwrap();
let project_manifest = project_dir.join("manifest.toml");
let project_config = project_dir.join("profiles.toml");
std::fs::write(
&project_manifest,
&project_config,
r#"
[profiles]
default = "work"
[profiles.profile]
[profile]
coder = "profiles/coder.nix"
[profiles.alias]
[alias]
work = "coder"
"#,
)
.unwrap();
let registry = ProfileDiscovery::with_sources(None, None, Some(project_manifest))
let registry = ProfileDiscovery::with_sources(None, None, Some(project_config))
.discover()
.unwrap();
let default = registry.default_entry().unwrap();

View File

@ -2,7 +2,8 @@
//!
//! Rendered at the user's current cursor position when `insomnia` is invoked
//! with no positional argument. Walks the cwd for a `.insomnia/manifest.toml`
//! to seed defaults, prompts for the Pod's name, and on confirmation
//! to seed manifest defaults and `.insomnia/profiles.toml` to discover profile
//! choices, prompts for the Pod's name, and on confirmation
//! launches the `insomnia-pod` binary as an independent process with a freshly built
//! overlay (name + cwd scope when no project manifest exists). Once
//! the process reports its socket via the `INSOMNIA-READY` stderr line,
@ -906,13 +907,12 @@ permission = "write"
let insomnia = project.join(".insomnia");
std::fs::create_dir_all(&insomnia).unwrap();
std::fs::write(
insomnia.join("manifest.toml"),
insomnia.join("profiles.toml"),
r#"
[profiles]
default = "work"
[profiles.profile]
[profile]
coder = "profiles/coder.nix"
[profiles.alias]
[alias]
work = "coder"
"#,
)
@ -933,11 +933,10 @@ work = "coder"
let insomnia = project.join(".insomnia");
std::fs::create_dir_all(&insomnia).unwrap();
std::fs::write(
insomnia.join("manifest.toml"),
insomnia.join("profiles.toml"),
r#"
[profiles]
default = "coder"
[profiles.profile.coder]
[profile.coder]
path = "profiles/coder.nix"
description = "Project coder"
"#,

View File

@ -41,31 +41,30 @@ insomnia --profile ./coder.nix
## 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.
Profile discovery is separate from runtime manifest merging. User/project `profiles.toml` files may declare profile registry metadata, but those files are application/project UX configuration and are not merged into the Nix profile artifact.
Example project config at `.insomnia/manifest.toml`:
Example project config at `.insomnia/profiles.toml`:
```toml
[profiles]
default = "coder"
[profiles.profile]
[profile]
coder = "profiles/coder.nix"
reviewer = "profiles/reviewer.nix"
[profiles.alias]
[alias]
work = "project:coder"
```
Table entries can carry descriptions:
```toml
[profiles.profile.coder]
[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 alias/default targets resolve within the declaring source by default. Unqualified ambiguous names fail closed:
Relative registry paths are resolved against the `profiles.toml` file that declares them. Discovery checks bundled builtin profiles, then the user registry at `<config_dir>/profiles.toml`, then the nearest project registry at `.insomnia/profiles.toml`. Later defaults override earlier defaults, so a project default wins over a user default. Unqualified alias/default targets resolve within the declaring source by default. Unqualified ambiguous names fail closed:
```sh
insomnia --profile coder # fails if both user:coder and project:coder exist