From 2ed4bd007b863c383c9f4a73ad4df1d17ae0abdc Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 27 Apr 2026 16:52:23 +0900 Subject: [PATCH] =?UTF-8?q?manifest=E5=81=B4=E3=81=A7=E8=A8=AD=E5=AE=9A?= =?UTF-8?q?=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E3=81=AE=E5=8F=8E=E9=9B=86?= =?UTF-8?q?=E3=82=92=E8=A1=8C=E3=81=86=E3=82=88=E3=81=86=E3=81=AB=E3=81=97?= =?UTF-8?q?=E3=81=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/manifest/src/cascade.rs | 175 +++++++++++++++++++++++++++++++++ crates/manifest/src/lib.rs | 4 + crates/pod/src/factory.rs | 73 +++++--------- crates/tui/src/spawn.rs | 50 ++-------- tickets/tui-pod-spawn-ui.md | 12 ++- 5 files changed, 222 insertions(+), 92 deletions(-) create mode 100644 crates/manifest/src/cascade.rs diff --git a/crates/manifest/src/cascade.rs b/crates/manifest/src/cascade.rs new file mode 100644 index 00000000..4c24c8bf --- /dev/null +++ b/crates/manifest/src/cascade.rs @@ -0,0 +1,175 @@ +//! Cascade-layer collection helpers. +//! +//! Pod manifests are assembled from up to three on-disk layers (see +//! `pod::PodFactory` for the full cascade story): +//! +//! 1. **User manifest** at `$XDG_CONFIG_HOME/insomnia/manifest.toml`, +//! falling back to `$HOME/.config/insomnia/manifest.toml` +//! 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 +//! +//! This module owns the conventions for (1) and (2): where each file +//! lives and how to parse it. Callers (pod's factory, the TUI's spawn +//! UI, future GUI flows) all share these helpers so the conventions +//! live in one place. +//! +//! Cascade *merging* and final validation stay outside this module — +//! that's the data layer's responsibility (`PodManifestConfig::merge` +//! and `PodManifest::try_from`). This module only handles the I/O and +//! path-discovery glue around them. + +use std::path::{Path, PathBuf}; + +use crate::PodManifestConfig; + +/// Errors returned when reading a single manifest layer from disk. +#[derive(Debug, thiserror::Error)] +pub enum LayerLoadError { + #[error("failed to read manifest {}: {source}", .path.display())] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("failed to parse manifest {}: {source}", .path.display())] + Parse { + path: PathBuf, + #[source] + source: toml::de::Error, + }, +} + +/// Conventional path of the user manifest: +/// `$XDG_CONFIG_HOME/insomnia/manifest.toml`, falling back to +/// `$HOME/.config/insomnia/manifest.toml`. Returns `None` when neither +/// env var is set to a non-empty value. +/// +/// Existence of the file is **not** checked here — callers decide +/// whether a missing file is an error or a silent skip. +pub fn user_manifest_path() -> Option { + if let Ok(dir) = std::env::var("XDG_CONFIG_HOME") { + if !dir.is_empty() { + return Some(PathBuf::from(dir).join("insomnia").join("manifest.toml")); + } + } + let home = std::env::var("HOME").ok().filter(|s| !s.is_empty())?; + Some( + PathBuf::from(home) + .join(".config") + .join("insomnia") + .join("manifest.toml"), + ) +} + +/// Walk up from `start` looking for `.insomnia/manifest.toml`. Returns +/// the closest match, or `None` if none is found before reaching the +/// filesystem root. +pub fn find_project_manifest_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("manifest.toml"); + if candidate.is_file() { + return Some(candidate); + } + cur = dir.parent(); + } + None +} + +/// Read a manifest file from `path` and parse it as a partial +/// [`PodManifestConfig`]. Path resolution against a base directory and +/// merging with other layers are the caller's responsibility. +pub fn load_layer(path: &Path) -> Result { + let toml = std::fs::read_to_string(path).map_err(|source| LayerLoadError::Io { + path: path.to_path_buf(), + source, + })?; + PodManifestConfig::from_toml(&toml).map_err(|source| LayerLoadError::Parse { + path: path.to_path_buf(), + source, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn find_project_manifest_walks_up() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path().canonicalize().unwrap(); + let manifest = root.join(".insomnia").join("manifest.toml"); + std::fs::create_dir_all(manifest.parent().unwrap()).unwrap(); + std::fs::write(&manifest, "").unwrap(); + + let nested = root.join("a").join("b"); + std::fs::create_dir_all(&nested).unwrap(); + + let found = find_project_manifest_from(&nested).unwrap(); + assert_eq!(found, manifest); + } + + #[test] + fn find_project_manifest_returns_none_when_absent() { + let tmp = TempDir::new().unwrap(); + assert!(find_project_manifest_from(tmp.path()).is_none()); + } + + #[test] + fn load_layer_round_trips_partial_config() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("manifest.toml"); + std::fs::write( + &path, + r#" +[pod] +name = "from-disk" +"#, + ) + .unwrap(); + let cfg = load_layer(&path).unwrap(); + assert_eq!(cfg.pod.name.as_deref(), Some("from-disk")); + } + + #[test] + fn load_layer_io_error_carries_path() { + let bogus = PathBuf::from("/definitely/does/not/exist/manifest.toml"); + let err = load_layer(&bogus).unwrap_err(); + match err { + LayerLoadError::Io { path, .. } => assert_eq!(path, bogus), + _ => panic!("expected Io variant"), + } + } + + #[test] + fn user_manifest_path_uses_xdg_when_set() { + let saved_xdg = std::env::var("XDG_CONFIG_HOME").ok(); + let saved_home = std::env::var("HOME").ok(); + // SAFETY: tests in this module are not run concurrently with + // env-mutating threads (cargo's default test harness already + // serialises tests inside one binary, and these helpers don't + // spawn threads of their own). + unsafe { + std::env::set_var("XDG_CONFIG_HOME", "/tmp/xdg-conf"); + std::env::remove_var("HOME"); + } + let p = user_manifest_path().unwrap(); + assert_eq!(p, PathBuf::from("/tmp/xdg-conf/insomnia/manifest.toml")); + unsafe { + match saved_xdg { + Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } + match saved_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + } + } +} diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index e9b97cf9..65043915 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -1,8 +1,12 @@ +mod cascade; mod config; pub mod defaults; mod model; mod scope; +pub use cascade::{ + LayerLoadError, find_project_manifest_from, load_layer, user_manifest_path, +}; pub use config::{ CompactionConfigPartial, PodManifestConfig, PodMetaConfig, ResolveError, ToolOutputLimitsPartial, WorkerManifestConfig, diff --git a/crates/pod/src/factory.rs b/crates/pod/src/factory.rs index bea73791..50f78164 100644 --- a/crates/pod/src/factory.rs +++ b/crates/pod/src/factory.rs @@ -27,7 +27,10 @@ use std::path::{Path, PathBuf}; -use manifest::{PodManifest, PodManifestConfig, ResolveError}; +use manifest::{ + LayerLoadError, PodManifest, PodManifestConfig, ResolveError, find_project_manifest_from, + load_layer, user_manifest_path, +}; use crate::prompt::loader::PromptLoader; @@ -50,8 +53,15 @@ pub enum FactoryError { OverlayParse(#[source] toml::de::Error), #[error("failed to resolve manifest config: {0}")] Resolve(#[source] ResolveError), - #[error("cannot locate home directory for user manifest lookup")] - HomeDirUnavailable, +} + +impl From for FactoryError { + fn from(e: LayerLoadError) -> Self { + match e { + LayerLoadError::Io { path, source } => Self::Io { path, source }, + LayerLoadError::Parse { path, source } => Self::Parse { path, source }, + } + } } /// Builder that accumulates cascade layers and resolves them to a @@ -94,14 +104,16 @@ impl PodFactory { /// Attempt to load the user manifest from the XDG config directory. /// /// Looks at `$XDG_CONFIG_HOME/insomnia/manifest.toml` first, then - /// falls back to `$HOME/.config/insomnia/manifest.toml`. If the - /// resolved file does not exist the call is a no-op — user - /// manifests are optional. + /// falls back to `$HOME/.config/insomnia/manifest.toml`. If neither + /// env var is set, or the resolved file does not exist, the call + /// is a no-op — user manifests are optional. pub fn with_user_manifest_auto(mut self) -> Result { - let path = user_manifest_path()?; + let Some(path) = user_manifest_path() else { + return Ok(self); + }; if path.exists() { let base = manifest_base(&path)?; - self.user = Some((read_config_file(&path)?, base.clone())); + self.user = Some((load_layer(&path)?, base.clone())); self.user_prompts_dir = Some(base.join("prompts")); self.user_pack_file = Some(base.join("prompts.toml")); } @@ -113,7 +125,7 @@ impl PodFactory { pub fn with_user_manifest(mut self, path: impl AsRef) -> Result { let path = path.as_ref(); let base = manifest_base(path)?; - self.user = Some((read_config_file(path)?, base.clone())); + self.user = Some((load_layer(path)?, base.clone())); self.user_prompts_dir = Some(base.join("prompts")); self.user_pack_file = Some(base.join("prompts.toml")); Ok(self) @@ -127,7 +139,7 @@ impl PodFactory { path: PathBuf::from("."), source, })?; - if let Some(path) = find_project_manifest(&cwd) { + if let Some(path) = find_project_manifest_from(&cwd) { self.install_project_manifest(&path)?; } Ok(self) @@ -139,7 +151,7 @@ impl PodFactory { mut self, start: impl AsRef, ) -> Result { - if let Some(path) = find_project_manifest(start.as_ref()) { + if let Some(path) = find_project_manifest_from(start.as_ref()) { self.install_project_manifest(&path)?; } Ok(self) @@ -156,7 +168,7 @@ impl PodFactory { .parent() .map(Path::to_path_buf) .unwrap_or_else(|| insomnia_dir.clone()); - self.project = Some((read_config_file(path)?, project_root)); + self.project = Some((load_layer(path)?, project_root)); self.project_prompts_dir = Some(insomnia_dir.join("prompts")); self.project_pack_file = Some(insomnia_dir.join("prompts.toml")); Ok(()) @@ -283,43 +295,6 @@ fn resolve_and_merge_overlay( }) } -fn user_manifest_path() -> Result { - if let Ok(dir) = std::env::var("XDG_CONFIG_HOME") { - if !dir.is_empty() { - return Ok(PathBuf::from(dir).join("insomnia").join("manifest.toml")); - } - } - let home = std::env::var("HOME").map_err(|_| FactoryError::HomeDirUnavailable)?; - Ok(PathBuf::from(home) - .join(".config") - .join("insomnia") - .join("manifest.toml")) -} - -fn find_project_manifest(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("manifest.toml"); - if candidate.is_file() { - return Some(candidate); - } - cur = dir.parent(); - } - None -} - -fn read_config_file(path: &Path) -> Result { - let toml = std::fs::read_to_string(path).map_err(|source| FactoryError::Io { - path: path.to_path_buf(), - source, - })?; - PodManifestConfig::from_toml(&toml).map_err(|source| FactoryError::Parse { - path: path.to_path_buf(), - source, - }) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/tui/src/spawn.rs b/crates/tui/src/spawn.rs index 95a608e5..0142ef55 100644 --- a/crates/tui/src/spawn.rs +++ b/crates/tui/src/spawn.rs @@ -13,14 +13,16 @@ //! user has a record of what was spawned (or why a spawn failed). use std::io; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::process::Stdio; use std::time::Duration; use crossterm::event::{ self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers, }; -use manifest::PodManifestConfig; +use manifest::{ + PodManifestConfig, find_project_manifest_from, load_layer, user_manifest_path, +}; use ratatui::Terminal; use ratatui::backend::CrosstermBackend; use ratatui::layout::{Constraint, Layout}; @@ -95,8 +97,10 @@ pub async fn run() -> Result { // is intentionally an instance-level identifier and is always // taken from the dialog regardless of what (if anything) a layer // declared. - let user_layer = user_manifest_path().and_then(load_layer); - let project_layer = find_project_manifest(&cwd).and_then(load_layer); + let user_layer = 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(); for layer in [user_layer.as_ref(), project_layer.as_ref()].into_iter().flatten() { @@ -372,44 +376,6 @@ fn resolve_pod_command() -> PathBuf { PathBuf::from("pod") } -fn find_project_manifest(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("manifest.toml"); - if candidate.is_file() { - return Some(candidate); - } - cur = dir.parent(); - } - None -} - -fn load_layer(path: PathBuf) -> Option { - if !path.is_file() { - return None; - } - let s = std::fs::read_to_string(&path).ok()?; - PodManifestConfig::from_toml(&s).ok() -} - -fn user_manifest_path() -> Option { - if let Ok(dir) = std::env::var("XDG_CONFIG_HOME") { - if !dir.is_empty() { - return Some(PathBuf::from(dir).join("insomnia").join("manifest.toml")); - } - } - let home = std::env::var("HOME").ok()?; - Some( - PathBuf::from(home) - .join(".config") - .join("insomnia") - .join("manifest.toml"), - ) -} struct StderrTail { lines: std::collections::VecDeque, diff --git a/tickets/tui-pod-spawn-ui.md b/tickets/tui-pod-spawn-ui.md index cb7c495e..56ab2ad4 100644 --- a/tickets/tui-pod-spawn-ui.md +++ b/tickets/tui-pod-spawn-ui.md @@ -49,7 +49,17 @@ pod は `pod.name` / `model` / `scope.allow` がそろわないと起動しな - **`model`**: user / project どちらかのレイヤから cascade 経由で取得。どこにも無ければ pod 側の resolve が失敗するので、その stderr エラー文を inline ダイアログに表示してキャンセル相当に倒す(user manifest の編集 UI までは本チケット外) - **`scope.allow`**: user / project どちらかのレイヤに既にあればそれをそのまま使う(overlay には追加しない)。両方とも無ければ `target = , permission = "write"` をデフォルトとして overlay に追加する -tui は `manifest` クレートの `PodManifestConfig::from_toml` / `merge` / `resolve_paths` を使って user + project の cascade を実際にマージし、その結果から「`scope.allow` が空かどうか」「`pod.name` のデフォルトは何か」を読み取る。実際の最終マージ + バリデーションは pod の `PodFactory::resolve()` 側で行われるので、tui は dialog 用の事前情報を取るためだけに同じ仕組みを再実行する形になる(重い `pod` クレート全体には依存しない)。 +tui は `manifest` クレートの `PodManifestConfig::from_toml` / `merge` を使って user + project の cascade を実際にマージし、その結果から「`scope.allow` が空かどうか」を読み取る。実際の最終マージ + バリデーションは pod の `PodFactory::resolve()` 側で行われるので、tui は dialog 用の事前情報を取るためだけに同じ仕組みを再実行する形になる(重い `pod` クレート全体には依存しない)。 + +### `manifest` クレートへのカスケード収集 API 移管 + +カスケードのファイルシステム規約(user manifest の XDG パス、project manifest の `.insomnia/manifest.toml` 上方探索、TOML 読み込み + パース)は、当初 pod の `factory.rs` 内 private に実装されていた。本チケットで tui が同じ規約を必要としたため、二箇所に重複させるのではなく **`manifest` クレートに公開 API として移管**する。 + +- `manifest::user_manifest_path() -> Option` +- `manifest::find_project_manifest_from(start: &Path) -> Option` +- `manifest::load_layer(path: &Path) -> Result` + +pod の `PodFactory` も tui の spawn UI もこれらを呼ぶ形に統一し、規約は manifest に一箇所で持つ。`PodFactory` 自体(builder + PromptLoader 抱える型)は引き続き pod の責務として残す。 確定時、ダイアログ入力 + デフォルト埋めから overlay TOML を組み、pod に `--overlay` で渡す。pod 側の cascade は user → project → overlay の順で merge されるので、project manifest に値があれば overlay の同名フィールドだけが上書きする形になる。