From 80a4f90004d90fe544a63312423334f9e7e6c5ef Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 26 May 2026 10:09:17 +0900 Subject: [PATCH] fix: align spawn user manifest env overlay --- crates/manifest/src/cascade.rs | 3 +- crates/manifest/src/lib.rs | 4 +- crates/manifest/src/paths.rs | 69 +++++++++++++++++++++++++++++++++- crates/pod/src/main.rs | 21 +++-------- crates/tui/src/spawn.rs | 43 +++++++++++++++++++-- 5 files changed, 118 insertions(+), 22 deletions(-) diff --git a/crates/manifest/src/cascade.rs b/crates/manifest/src/cascade.rs index 7c11c8dc..718884b2 100644 --- a/crates/manifest/src/cascade.rs +++ b/crates/manifest/src/cascade.rs @@ -3,7 +3,8 @@ //! Pod manifests are assembled from up to three on-disk layers (see //! `pod::PodFactory` for the full cascade story): //! -//! 1. **User manifest** — see [`crate::paths::user_manifest_path`] +//! 1. **User manifest** — Pod CLI uses +//! [`crate::paths::user_manifest_path_with_env_override`] //! 2. **Project manifest** at the closest `.insomnia/manifest.toml` //! found by walking up from a starting directory (typically `cwd`) //! 3. **Programmatic overlay** supplied at the call site diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 6f152b69..254df718 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -13,7 +13,9 @@ pub use config::{ pub use model::{ AuthRef, ModelCapability, ModelManifest, ReasoningControl, ReasoningEffort, SchemeKind, }; -pub use paths::user_manifest_path; +pub use paths::{ + user_manifest_path, user_manifest_path_from_env, user_manifest_path_with_env_override, +}; pub use protocol::{Permission, ScopeRule}; pub use scope::{Scope, ScopeError, SharedScope}; diff --git a/crates/manifest/src/paths.rs b/crates/manifest/src/paths.rs index 96f9873f..f8cccc75 100644 --- a/crates/manifest/src/paths.rs +++ b/crates/manifest/src/paths.rs @@ -23,8 +23,16 @@ //! 解決された各 base が存在するか / ディレクトリかは保証しない — //! 呼び出し側がファイル操作の前に作成 / 検査する。 +use std::ffi::OsString; use std::path::PathBuf; +/// Environment variable that points at an explicit user manifest. +/// +/// Pod CLI treats a non-empty value as an explicit manifest path. Empty values +/// are treated the same as an unset variable, so callers fall back to the +/// auto-discovered user manifest path. +pub const USER_MANIFEST_ENV: &str = "INSOMNIA_USER_MANIFEST"; + /// 設定ディレクトリ。`manifest.toml`, `providers.toml`, `models.toml`, /// `prompts/` などが置かれる。 pub fn config_dir() -> Option { @@ -69,11 +77,38 @@ pub fn runtime_dir() -> Option { // ---- well-known file getters ------------------------------------------------ -/// `/manifest.toml` — user manifest。 +/// `/manifest.toml` — user manifest の既定位置。 +/// +/// This deliberately ignores [`USER_MANIFEST_ENV`]. Use +/// [`user_manifest_path_with_env_override`] when mirroring the Pod CLI cascade +/// resolution rules. pub fn user_manifest_path() -> Option { Some(config_dir()?.join("manifest.toml")) } +/// Resolve an explicit user manifest override from an env value. +/// +/// Non-empty values are paths. `None` and empty strings are both treated as no +/// override, matching the Pod CLI's `INSOMNIA_USER_MANIFEST` handling. +pub fn user_manifest_path_from_env(value: Option) -> Option { + value.and_then(|value| { + if value.as_os_str().is_empty() { + None + } else { + Some(PathBuf::from(value)) + } + }) +} + +/// User manifest path using the same env override rule as the Pod CLI cascade. +/// +/// A non-empty [`USER_MANIFEST_ENV`] value wins. If the variable is unset or +/// empty, this falls back to [`user_manifest_path`]. The returned path is not +/// guaranteed to exist. +pub fn user_manifest_path_with_env_override() -> Option { + user_manifest_path_from_env(std::env::var_os(USER_MANIFEST_ENV)).or_else(user_manifest_path) +} + /// `/prompts/` — user prompts ライブラリ。 pub fn user_prompts_dir() -> Option { Some(config_dir()?.join("prompts")) @@ -156,6 +191,7 @@ mod tests { "INSOMNIA_CONFIG_DIR", "INSOMNIA_DATA_DIR", "INSOMNIA_RUNTIME_DIR", + "INSOMNIA_USER_MANIFEST", "INSOMNIA_HOME", "XDG_CONFIG_HOME", "XDG_RUNTIME_DIR", @@ -281,6 +317,37 @@ mod tests { assert!(runtime_dir().is_none()); } + #[test] + fn user_manifest_env_override_wins_when_non_empty() { + let _g = EnvGuard::new(&[ + ("HOME", Some("/h")), + ("INSOMNIA_USER_MANIFEST", Some("/tmp/user.toml")), + ]); + assert_eq!( + user_manifest_path_with_env_override().unwrap(), + PathBuf::from("/tmp/user.toml") + ); + } + + #[test] + fn empty_user_manifest_env_falls_back_to_default_path() { + let _g = EnvGuard::new(&[("HOME", Some("/h")), ("INSOMNIA_USER_MANIFEST", Some(""))]); + assert_eq!( + user_manifest_path_with_env_override().unwrap(), + PathBuf::from("/h/.config/insomnia/manifest.toml") + ); + } + + #[test] + fn user_manifest_path_from_env_treats_empty_as_unset() { + assert_eq!(user_manifest_path_from_env(None), None); + assert_eq!(user_manifest_path_from_env(Some(OsString::from(""))), None); + assert_eq!( + user_manifest_path_from_env(Some(OsString::from("/tmp/u.toml"))).unwrap(), + PathBuf::from("/tmp/u.toml") + ); + } + #[test] fn well_known_files_compose_off_base_dirs() { let _g = EnvGuard::new(&[("INSOMNIA_HOME", Some("/sand"))]); diff --git a/crates/pod/src/main.rs b/crates/pod/src/main.rs index ba5ab73f..2aef3c77 100644 --- a/crates/pod/src/main.rs +++ b/crates/pod/src/main.rs @@ -7,8 +7,6 @@ use manifest::{PodManifest, PodManifestConfig, paths}; use pod::{Pod, PodController, PodFactory, PromptLoader}; use session_store::{FsStore, PodMetadataStore, SegmentId, Store}; -const USER_MANIFEST_ENV: &str = "INSOMNIA_USER_MANIFEST"; - #[derive(Debug, Parser)] #[command( name = "pod", @@ -68,19 +66,20 @@ struct Cli { } fn resolve_manifest(cli: &Cli) -> Result<(PodManifest, PromptLoader), String> { - resolve_manifest_with_user_manifest_env(cli, std::env::var_os(USER_MANIFEST_ENV)) + resolve_manifest_with_user_manifest_env(cli, std::env::var_os(paths::USER_MANIFEST_ENV)) } fn resolve_manifest_with_user_manifest_env( cli: &Cli, user_manifest_env: Option, ) -> Result<(PodManifest, PromptLoader), String> { - let user_manifest = user_manifest_path_from_env(user_manifest_env); + let user_manifest = paths::user_manifest_path_from_env(user_manifest_env); if let Some(path) = &cli.manifest { if user_manifest.is_some() { return Err(format!( - "--manifest cannot be used when {USER_MANIFEST_ENV} is set" + "--manifest cannot be used when {} is set", + paths::USER_MANIFEST_ENV )); } return load_single_manifest(path, cli.pod.as_deref()); @@ -92,16 +91,6 @@ fn resolve_manifest_with_user_manifest_env( .map_err(|e| format!("failed to resolve manifest cascade: {e}")) } -fn user_manifest_path_from_env(value: Option) -> Option { - value.and_then(|value| { - if value.is_empty() { - None - } else { - Some(PathBuf::from(value)) - } - }) -} - fn load_single_manifest( path: &Path, pod_name_override: Option<&str>, @@ -408,7 +397,7 @@ permission = "write" .unwrap_err(); assert!(err.contains("--manifest cannot be used")); - assert!(err.contains(USER_MANIFEST_ENV)); + assert!(err.contains(paths::USER_MANIFEST_ENV)); } #[test] diff --git a/crates/tui/src/spawn.rs b/crates/tui/src/spawn.rs index 235a6318..b525f6b0 100644 --- a/crates/tui/src/spawn.rs +++ b/crates/tui/src/spawn.rs @@ -12,6 +12,7 @@ //! The viewport's last frame stays in the terminal's scrollback so the //! user has a record of what was spawned (or why a spawn failed). +use std::ffi::OsString; use std::io; use std::path::PathBuf; use std::time::Duration; @@ -20,6 +21,7 @@ use client::{SpawnConfig, spawn_pod}; use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers}; use manifest::{ PodManifestConfig, ScopeConfig, find_project_manifest_from, load_layer, user_manifest_path, + user_manifest_path_from_env, }; use ratatui::Terminal; use ratatui::backend::CrosstermBackend; @@ -210,9 +212,15 @@ fn load_spawn_defaults() -> Result { // Run the same merge pod itself uses, then read what's missing off the // result. We only look at `scope.allow` here — `pod.name` is an // instance-level identifier and is supplied by the dialog or `--pod`. - let user_layer = user_manifest_path() - .filter(|p| p.is_file()) - .and_then(|p| load_layer(&p).ok()); + // TUI must pre-read the same user manifest path that the pod CLI will use, + // including a non-empty INSOMNIA_USER_MANIFEST override; empty values fall + // back to the auto-discovered path. + let user_layer = user_manifest_path_for_spawn( + std::env::var_os(manifest::paths::USER_MANIFEST_ENV), + user_manifest_path(), + ) + .filter(|p| p.is_file()) + .and_then(|p| load_layer(&p).ok()); let project_layer = find_project_manifest_from(&cwd).and_then(|p| load_layer(&p).ok()); let mut cascade = PodManifestConfig::builtin_defaults(); @@ -252,6 +260,13 @@ fn load_spawn_defaults() -> Result { }) } +fn user_manifest_path_for_spawn( + env_value: Option, + default_user_manifest: Option, +) -> Option { + user_manifest_path_from_env(env_value).or(default_user_manifest) +} + fn form_for_pod_name(pod_name: String, defaults: SpawnDefaults) -> Form { Form { cwd: defaults.cwd, @@ -712,6 +727,28 @@ permission = "write" assert!(empty_cascade.scope.allow.is_empty()); } + #[test] + fn user_manifest_path_for_spawn_prefers_non_empty_env_override() { + assert_eq!( + user_manifest_path_for_spawn( + Some(OsString::from("/tmp/override.toml")), + Some(PathBuf::from("/default/manifest.toml")), + ), + Some(PathBuf::from("/tmp/override.toml")), + ); + } + + #[test] + fn user_manifest_path_for_spawn_treats_empty_env_as_unset() { + assert_eq!( + user_manifest_path_for_spawn( + Some(OsString::from("")), + Some(PathBuf::from("/default/manifest.toml")), + ), + Some(PathBuf::from("/default/manifest.toml")), + ); + } + #[test] fn name_input_handles_insert_backspace_and_cursor() { let mut f = form("", false);