manifest側で設定ファイルの収集を行うようにした
This commit is contained in:
parent
5ebdeff76d
commit
2ed4bd007b
175
crates/manifest/src/cascade.rs
Normal file
175
crates/manifest/src/cascade.rs
Normal file
|
|
@ -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<PathBuf> {
|
||||
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<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("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<PodManifestConfig, LayerLoadError> {
|
||||
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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<LayerLoadError> 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<Self, FactoryError> {
|
||||
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<Path>) -> Result<Self, FactoryError> {
|
||||
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<Path>,
|
||||
) -> Result<Self, FactoryError> {
|
||||
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<PathBuf, FactoryError> {
|
||||
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<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("manifest.toml");
|
||||
if candidate.is_file() {
|
||||
return Some(candidate);
|
||||
}
|
||||
cur = dir.parent();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn read_config_file(path: &Path) -> Result<PodManifestConfig, FactoryError> {
|
||||
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::*;
|
||||
|
|
|
|||
|
|
@ -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<SpawnOutcome, SpawnError> {
|
|||
// 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<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("manifest.toml");
|
||||
if candidate.is_file() {
|
||||
return Some(candidate);
|
||||
}
|
||||
cur = dir.parent();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn load_layer(path: PathBuf) -> Option<PodManifestConfig> {
|
||||
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<PathBuf> {
|
||||
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<String>,
|
||||
|
|
|
|||
|
|
@ -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 = <cwd>, 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<PathBuf>`
|
||||
- `manifest::find_project_manifest_from(start: &Path) -> Option<PathBuf>`
|
||||
- `manifest::load_layer(path: &Path) -> Result<PodManifestConfig, LayerLoadError>`
|
||||
|
||||
pod の `PodFactory` も tui の spawn UI もこれらを呼ぶ形に統一し、規約は manifest に一箇所で持つ。`PodFactory` 自体(builder + PromptLoader 抱える型)は引き続き pod の責務として残す。
|
||||
|
||||
確定時、ダイアログ入力 + デフォルト埋めから overlay TOML を組み、pod に `--overlay` で渡す。pod 側の cascade は user → project → overlay の順で merge されるので、project manifest に値があれば overlay の同名フィールドだけが上書きする形になる。
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user