chore: remove stale manifest cascade tests
This commit is contained in:
parent
2533a3d5d3
commit
e9354b38d6
|
|
@ -1,125 +0,0 @@
|
||||||
//! 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** — 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
|
|
||||||
//!
|
|
||||||
//! This module owns the project-layer discovery and the parser glue.
|
|
||||||
//! User-layer path resolution lives in [`crate::paths`].
|
|
||||||
//!
|
|
||||||
//! 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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -210,9 +210,9 @@ impl PodManifestConfig {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cascade layer populated with the in-code defaults listed in
|
/// Base config populated with the in-code defaults listed in
|
||||||
/// [`crate::defaults`]. Used by [`PodFactory::resolve`] as the
|
/// [`crate::defaults`]. Profile and one-file Manifest resolvers start
|
||||||
/// bottom layer, so every per-field default lives at exactly one
|
/// from this layer so every per-field default lives at exactly one
|
||||||
/// call site (the `defaults` module).
|
/// call site (the `defaults` module).
|
||||||
///
|
///
|
||||||
/// `TryFrom<PodManifestConfig>` also reads the same constants as a
|
/// `TryFrom<PodManifestConfig>` also reads the same constants as a
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
mod cascade;
|
|
||||||
mod config;
|
mod config;
|
||||||
pub mod defaults;
|
pub mod defaults;
|
||||||
mod model;
|
mod model;
|
||||||
|
|
@ -6,7 +5,6 @@ pub mod paths;
|
||||||
mod profile;
|
mod profile;
|
||||||
mod scope;
|
mod scope;
|
||||||
|
|
||||||
pub use cascade::{LayerLoadError, find_project_manifest_from, load_layer};
|
|
||||||
pub use config::{
|
pub use config::{
|
||||||
CompactionConfigPartial, FileUploadLimitsPartial, PermissionConfigPartial, PodManifestConfig,
|
CompactionConfigPartial, FileUploadLimitsPartial, PermissionConfigPartial, PodManifestConfig,
|
||||||
PodMetaConfig, ResolveError, SessionConfigPartial, ToolOutputLimitsPartial,
|
PodMetaConfig, ResolveError, SessionConfigPartial, ToolOutputLimitsPartial,
|
||||||
|
|
@ -15,10 +13,7 @@ pub use config::{
|
||||||
pub use model::{
|
pub use model::{
|
||||||
AuthRef, ModelCapability, ModelManifest, ReasoningControl, ReasoningEffort, SchemeKind,
|
AuthRef, ModelCapability, ModelManifest, ReasoningControl, ReasoningEffort, SchemeKind,
|
||||||
};
|
};
|
||||||
pub use paths::{
|
pub use paths::user_profiles_path;
|
||||||
user_manifest_path, user_manifest_path_from_env, user_manifest_path_with_env_override,
|
|
||||||
user_profiles_path,
|
|
||||||
};
|
|
||||||
pub use profile::{
|
pub use profile::{
|
||||||
ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata, ProfileRegistry,
|
ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata, ProfileRegistry,
|
||||||
ProfileRegistryEntry, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver,
|
ProfileRegistryEntry, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver,
|
||||||
|
|
@ -76,17 +71,15 @@ pub struct PodManifest {
|
||||||
pub skills: Option<SkillsConfig>,
|
pub skills: Option<SkillsConfig>,
|
||||||
/// Optional profile provenance for manifests produced by profile resolution.
|
/// Optional profile provenance for manifests produced by profile resolution.
|
||||||
/// Stored only after profile resolution so Pod restore can prefer the
|
/// Stored only after profile resolution so Pod restore can prefer the
|
||||||
/// validated snapshot over ambient manifest cascade state.
|
/// validated snapshot over current profile files or one-file Manifest input.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub profile: Option<profile::ProfileManifestSnapshot>,
|
pub profile: Option<profile::ProfileManifestSnapshot>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// External Agent Skills (`SKILL.md`) ingest configuration. Skills are
|
/// External Agent Skills (`SKILL.md`) ingest configuration. Skills are
|
||||||
/// loaded *only* from the directories listed here — there is no
|
/// loaded *only* from the directories listed here — there is no
|
||||||
/// implicit `$config_dir/skills/` or builtin probe. Cascade-merged
|
/// implicit `$config_dir/skills/` or builtin probe. Profile and Manifest
|
||||||
/// across manifest layers, so a user-level manifest can declare a
|
/// resolution may compose these entries before validation.
|
||||||
/// shared skill root once while a project manifest adds its own
|
|
||||||
/// `.claude/skills/` / `.cursor/skills/` paths on top.
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct SkillsConfig {
|
pub struct SkillsConfig {
|
||||||
/// Skills *roots*. Children of each root must be individual
|
/// Skills *roots*. Children of each root must be individual
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
//!
|
//!
|
||||||
//! 用途別に三つの base directory を持つ:
|
//! 用途別に三つの base directory を持つ:
|
||||||
//!
|
//!
|
||||||
//! - **`config_dir`** — 人が手で書く / 編集する設定。`manifest.toml`,
|
//! - **`config_dir`** — 人が手で書く / 編集する設定。`profiles.toml`,
|
||||||
//! `providers.toml`, `models.toml`, `prompts/`, `prompts.toml` 等
|
//! `providers.toml`, `models.toml`, `prompts/`, `prompts.toml` 等
|
||||||
//! - **`data_dir`** — プログラムが書く永続データ。`sessions/` 等
|
//! - **`data_dir`** — プログラムが書く永続データ。`sessions/` 等
|
||||||
//! - **`runtime_dir`** — 再起動で消えてよいランタイム状態。socket,
|
//! - **`runtime_dir`** — 再起動で消えてよいランタイム状態。socket,
|
||||||
|
|
@ -23,20 +23,12 @@
|
||||||
//! 解決された各 base が存在するか / ディレクトリかは保証しない —
|
//! 解決された各 base が存在するか / ディレクトリかは保証しない —
|
||||||
//! 呼び出し側がファイル操作の前に作成 / 検査する。
|
//! 呼び出し側がファイル操作の前に作成 / 検査する。
|
||||||
|
|
||||||
use std::ffi::OsString;
|
|
||||||
use std::path::PathBuf;
|
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";
|
|
||||||
|
|
||||||
/// Environment variable that points at installed project resources.
|
/// Environment variable that points at installed project resources.
|
||||||
pub const RESOURCE_DIR_ENV: &str = "INSOMNIA_RESOURCE_DIR";
|
pub const RESOURCE_DIR_ENV: &str = "INSOMNIA_RESOURCE_DIR";
|
||||||
|
|
||||||
/// 設定ディレクトリ。`manifest.toml`, `providers.toml`, `models.toml`,
|
/// 設定ディレクトリ。`profiles.toml`, `providers.toml`, `models.toml`,
|
||||||
/// `prompts/` などが置かれる。
|
/// `prompts/` などが置かれる。
|
||||||
pub fn config_dir() -> Option<PathBuf> {
|
pub fn config_dir() -> Option<PathBuf> {
|
||||||
if let Some(p) = env_path("INSOMNIA_CONFIG_DIR") {
|
if let Some(p) = env_path("INSOMNIA_CONFIG_DIR") {
|
||||||
|
|
@ -80,42 +72,10 @@ pub fn runtime_dir() -> Option<PathBuf> {
|
||||||
|
|
||||||
// ---- well-known file getters ------------------------------------------------
|
// ---- well-known file getters ------------------------------------------------
|
||||||
|
|
||||||
/// `<config_dir>/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<PathBuf> {
|
|
||||||
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<OsString>) -> Option<PathBuf> {
|
|
||||||
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<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 configuration.
|
/// `<config_dir>/profiles.toml` — user profile registry/default configuration.
|
||||||
///
|
///
|
||||||
/// This is application/profile selection configuration, not a Pod manifest
|
/// This is application/profile selection configuration, not a Pod manifest
|
||||||
/// layer. It deliberately ignores [`USER_MANIFEST_ENV`].
|
/// layer.
|
||||||
pub fn user_profiles_path() -> Option<PathBuf> {
|
pub fn user_profiles_path() -> Option<PathBuf> {
|
||||||
Some(config_dir()?.join("profiles.toml"))
|
Some(config_dir()?.join("profiles.toml"))
|
||||||
}
|
}
|
||||||
|
|
@ -228,7 +188,6 @@ mod tests {
|
||||||
"INSOMNIA_CONFIG_DIR",
|
"INSOMNIA_CONFIG_DIR",
|
||||||
"INSOMNIA_DATA_DIR",
|
"INSOMNIA_DATA_DIR",
|
||||||
"INSOMNIA_RUNTIME_DIR",
|
"INSOMNIA_RUNTIME_DIR",
|
||||||
"INSOMNIA_USER_MANIFEST",
|
|
||||||
"INSOMNIA_RESOURCE_DIR",
|
"INSOMNIA_RESOURCE_DIR",
|
||||||
"INSOMNIA_HOME",
|
"INSOMNIA_HOME",
|
||||||
"XDG_CONFIG_HOME",
|
"XDG_CONFIG_HOME",
|
||||||
|
|
@ -355,44 +314,9 @@ mod tests {
|
||||||
assert!(runtime_dir().is_none());
|
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]
|
#[test]
|
||||||
fn well_known_files_compose_off_base_dirs() {
|
fn well_known_files_compose_off_base_dirs() {
|
||||||
let _g = EnvGuard::new(&[("INSOMNIA_HOME", Some("/sand"))]);
|
let _g = EnvGuard::new(&[("INSOMNIA_HOME", Some("/sand"))]);
|
||||||
assert_eq!(
|
|
||||||
user_manifest_path().unwrap(),
|
|
||||||
PathBuf::from("/sand/config/manifest.toml")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
user_profiles_path().unwrap(),
|
user_profiles_path().unwrap(),
|
||||||
PathBuf::from("/sand/config/profiles.toml")
|
PathBuf::from("/sand/config/profiles.toml")
|
||||||
|
|
|
||||||
|
|
@ -1186,7 +1186,6 @@ mod tests {
|
||||||
let lock = env_lock();
|
let lock = env_lock();
|
||||||
let names = [
|
let names = [
|
||||||
"INSOMNIA_CONFIG_DIR",
|
"INSOMNIA_CONFIG_DIR",
|
||||||
"INSOMNIA_USER_MANIFEST",
|
|
||||||
"INSOMNIA_RESOURCE_DIR",
|
"INSOMNIA_RESOURCE_DIR",
|
||||||
"INSOMNIA_HOME",
|
"INSOMNIA_HOME",
|
||||||
"XDG_CONFIG_HOME",
|
"XDG_CONFIG_HOME",
|
||||||
|
|
@ -1456,61 +1455,6 @@ return profile {
|
||||||
assert!(err.to_string().contains("Lua profiles must end in .lua"));
|
assert!(err.to_string().contains("Lua profiles must end in .lua"));
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn for_cwd_reads_profiles_toml_and_ignores_manifest_profiles() {
|
|
||||||
let tmp = TempDir::new().unwrap();
|
|
||||||
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(
|
|
||||||
insomnia.join("manifest.toml"),
|
|
||||||
"[profiles]\ndefault = \"wrong\"\n[profiles.profile]\nwrong = \"wrong.lua\"\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
std::fs::write(
|
|
||||||
insomnia.join("profiles.toml"),
|
|
||||||
"default = \"coder\"\n[profile]\ncoder = \"profiles/coder.lua\"\n",
|
|
||||||
)
|
|
||||||
.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,
|
|
||||||
"[profiles]\ndefault = \"wrong\"\n[profiles.profile]\nwrong = \"wrong.lua\"\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
std::fs::write(
|
|
||||||
config_dir.join("profiles.toml"),
|
|
||||||
"default = \"coder\"\n[profile]\ncoder = \"profiles/coder.lua\"\n",
|
|
||||||
)
|
|
||||||
.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() {
|
fn discovery_reads_user_and_project_registry_and_project_default_wins() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let user_config = tmp.path().join("profiles.toml");
|
let user_config = tmp.path().join("profiles.toml");
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
//! `WorkspaceLayout` carries the workspace root (typically the Pod's
|
//! `WorkspaceLayout` carries the workspace root (typically the Pod's
|
||||||
//! pwd). All insomnia-managed content lives under the conventional
|
//! pwd). All insomnia-managed content lives under the conventional
|
||||||
//! `<root>/.insomnia/` subdirectory — the same place that holds
|
//! `<root>/.insomnia/` subdirectory — the same place that holds
|
||||||
//! `manifest.toml` and `prompts/`. The trees inside it:
|
//! `profiles.toml`, `prompts/`, workflow, knowledge, and generated
|
||||||
|
//! memory. The trees inside it:
|
||||||
//!
|
//!
|
||||||
//! - `<root>/.insomnia/workflow/<slug>.md`
|
//! - `<root>/.insomnia/workflow/<slug>.md`
|
||||||
//! - `<root>/.insomnia/knowledge/<slug>.md`
|
//! - `<root>/.insomnia/knowledge/<slug>.md`
|
||||||
|
|
|
||||||
|
|
@ -1,686 +0,0 @@
|
||||||
//! Builder that assembles a [`PodManifest`] from cascade layers.
|
|
||||||
//!
|
|
||||||
//! Layers are merged in order of increasing priority:
|
|
||||||
//! 1. **Builtin defaults** — in-code defaults, currently empty. Upper
|
|
||||||
//! layers provide everything; `TryFrom<PodManifestConfig>` fills in
|
|
||||||
//! per-field defaults (`ToolOutputLimits`, `CompactionConfig`, ...).
|
|
||||||
//! 2. **User manifest** — `$XDG_CONFIG_HOME/insomnia/manifest.toml`
|
|
||||||
//! (falling back to `~/.config/insomnia/manifest.toml`).
|
|
||||||
//! 3. **Project manifest** — closest `.insomnia/manifest.toml` found by
|
|
||||||
//! walking up from `cwd`.
|
|
||||||
//! 4. **Programmatic overlay** — inline TOML string or typed
|
|
||||||
//! [`PodManifestConfig`] supplied by the caller (CLI flags, GUI,
|
|
||||||
//! spawning Pod, etc.). Highest priority.
|
|
||||||
//!
|
|
||||||
//! Path resolution happens **before** merge. Each layer is resolved
|
|
||||||
//! against its own base directory so that a relative `target = "."`
|
|
||||||
//! in the project manifest means the project root regardless of how
|
|
||||||
//! the user or overlay layers lay out their own paths:
|
|
||||||
//!
|
|
||||||
//! - user manifest: base = the directory holding the manifest file
|
|
||||||
//! (which is `manifest::paths::config_dir()` when loaded via the
|
|
||||||
//! `_auto` variant)
|
|
||||||
//! - project manifest: base = the **project root** (the parent of
|
|
||||||
//! `.insomnia/`, not `.insomnia/` itself) so that natural project
|
|
||||||
//! manifests with `target = "."` cover the whole workspace
|
|
||||||
//! - overlay: base = the process's `current_dir()` at the time the
|
|
||||||
//! overlay is installed, since an inline TOML string has no file
|
|
||||||
//! location of its own
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use manifest::{
|
|
||||||
LayerLoadError, PodManifest, PodManifestConfig, ResolveError, find_project_manifest_from,
|
|
||||||
load_layer, paths,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::prompt::loader::PromptLoader;
|
|
||||||
|
|
||||||
/// Errors raised while building a [`PodManifest`] from cascade layers.
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum FactoryError {
|
|
||||||
#[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,
|
|
||||||
},
|
|
||||||
#[error("failed to parse overlay TOML: {0}")]
|
|
||||||
OverlayParse(#[source] toml::de::Error),
|
|
||||||
#[error("failed to resolve manifest config: {0}")]
|
|
||||||
Resolve(#[source] ResolveError),
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
/// validated [`PodManifest`].
|
|
||||||
///
|
|
||||||
/// Call order does not matter — layers are always merged in the fixed
|
|
||||||
/// priority order listed at the module level. Calling the same
|
|
||||||
/// `with_*` method twice overwrites the previous value for that slot.
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
pub struct PodFactory {
|
|
||||||
/// User layer paired with the directory the manifest lives in
|
|
||||||
/// (base for resolving its relative paths).
|
|
||||||
user: Option<(PodManifestConfig, PathBuf)>,
|
|
||||||
/// Project layer paired with the directory the manifest lives in.
|
|
||||||
project: Option<(PodManifestConfig, PathBuf)>,
|
|
||||||
/// Programmatic overlays are resolved against the process's
|
|
||||||
/// `current_dir()` at the time each call arrives, then merged into
|
|
||||||
/// this slot. Storing a pre-resolved (absolute-paths) config means
|
|
||||||
/// later overlay calls from a different cwd still work correctly.
|
|
||||||
overlay: Option<PodManifestConfig>,
|
|
||||||
/// Directory holding the user prompts library — co-located with
|
|
||||||
/// the user manifest when loaded. `<user_manifest_dir>/prompts/`.
|
|
||||||
user_prompts_dir: Option<PathBuf>,
|
|
||||||
/// `<project_root>/.insomnia/prompts/` — co-located with the
|
|
||||||
/// project manifest when loaded.
|
|
||||||
project_prompts_dir: Option<PathBuf>,
|
|
||||||
/// `<user_manifest_dir>/prompts.toml`, sibling of the user
|
|
||||||
/// prompts library. Consumed by the prompt catalog's user layer.
|
|
||||||
user_pack_file: Option<PathBuf>,
|
|
||||||
/// `<project_root>/.insomnia/prompts.toml`, sibling of the project
|
|
||||||
/// prompts library. Consumed by the prompt catalog's workspace layer.
|
|
||||||
project_pack_file: Option<PathBuf>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PodFactory {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attempt to load the user manifest from the user's config
|
|
||||||
/// directory (see [`manifest::paths::config_dir`] for how the path
|
|
||||||
/// is resolved). If 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 Some(path) = paths::user_manifest_path() else {
|
|
||||||
return Ok(self);
|
|
||||||
};
|
|
||||||
if path.exists() {
|
|
||||||
let base = manifest_base(&path)?;
|
|
||||||
self.user = Some((load_layer(&path)?, base.clone()));
|
|
||||||
self.user_prompts_dir = paths::user_prompts_dir();
|
|
||||||
self.user_pack_file = paths::user_pack_file();
|
|
||||||
}
|
|
||||||
Ok(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load the user manifest from an explicit path. The file must
|
|
||||||
/// exist; missing files are an error (unlike the `_auto` variant).
|
|
||||||
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((load_layer(path)?, base.clone()));
|
|
||||||
self.user_prompts_dir = Some(base.join("prompts"));
|
|
||||||
self.user_pack_file = Some(base.join("prompts.toml"));
|
|
||||||
Ok(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Walk up from `cwd` looking for a `.insomnia/manifest.toml` and
|
|
||||||
/// load it as the project layer. If no project root is found the
|
|
||||||
/// call is a no-op.
|
|
||||||
pub fn with_project_manifest_auto(mut self) -> Result<Self, FactoryError> {
|
|
||||||
let cwd = std::env::current_dir().map_err(|source| FactoryError::Io {
|
|
||||||
path: PathBuf::from("."),
|
|
||||||
source,
|
|
||||||
})?;
|
|
||||||
if let Some(path) = find_project_manifest_from(&cwd) {
|
|
||||||
self.install_project_manifest(&path)?;
|
|
||||||
}
|
|
||||||
Ok(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Walk up from `start` looking for a `.insomnia/manifest.toml`.
|
|
||||||
/// Explicit variant of [`with_project_manifest_auto`] for tests.
|
|
||||||
pub fn with_project_manifest_from(
|
|
||||||
mut self,
|
|
||||||
start: impl AsRef<Path>,
|
|
||||||
) -> Result<Self, FactoryError> {
|
|
||||||
if let Some(path) = find_project_manifest_from(start.as_ref()) {
|
|
||||||
self.install_project_manifest(&path)?;
|
|
||||||
}
|
|
||||||
Ok(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shared setup for `with_project_manifest_auto` / `_from`: record
|
|
||||||
/// the manifest's project root as the base for relative-path
|
|
||||||
/// resolution (the parent of `.insomnia/`, not `.insomnia/` itself)
|
|
||||||
/// so `target = "."` in a project manifest means the project root.
|
|
||||||
/// `prompts/` still lives inside `.insomnia/`.
|
|
||||||
fn install_project_manifest(&mut self, path: &Path) -> Result<(), FactoryError> {
|
|
||||||
let insomnia_dir = manifest_base(path)?;
|
|
||||||
let project_root = insomnia_dir
|
|
||||||
.parent()
|
|
||||||
.map(Path::to_path_buf)
|
|
||||||
.unwrap_or_else(|| insomnia_dir.clone());
|
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Install a programmatic overlay parsed from a TOML string. Any
|
|
||||||
/// relative paths in the overlay are resolved against the process's
|
|
||||||
/// current working directory at the time of this call — an inline
|
|
||||||
/// TOML string has no file location of its own.
|
|
||||||
pub fn with_overlay_toml(mut self, toml: &str) -> Result<Self, FactoryError> {
|
|
||||||
let config = PodManifestConfig::from_toml(toml).map_err(FactoryError::OverlayParse)?;
|
|
||||||
self.overlay = Some(resolve_and_merge_overlay(self.overlay, config)?);
|
|
||||||
Ok(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Install a programmatic overlay from an already-parsed config.
|
|
||||||
/// Behaves like [`Self::with_overlay_toml`] regarding relative paths.
|
|
||||||
pub fn with_overlay_config(mut self, config: PodManifestConfig) -> Result<Self, FactoryError> {
|
|
||||||
self.overlay = Some(resolve_and_merge_overlay(self.overlay, config)?);
|
|
||||||
Ok(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a [`PromptLoader`] that reflects the user / project
|
|
||||||
/// prompt directories registered with this factory (a sibling of
|
|
||||||
/// each manifest file: `prompts/`). Missing directories are
|
|
||||||
/// silently skipped.
|
|
||||||
fn build_prompt_loader(&self) -> PromptLoader {
|
|
||||||
let user = self
|
|
||||||
.user_prompts_dir
|
|
||||||
.as_ref()
|
|
||||||
.filter(|p| p.is_dir())
|
|
||||||
.cloned();
|
|
||||||
let project = self
|
|
||||||
.project_prompts_dir
|
|
||||||
.as_ref()
|
|
||||||
.filter(|p| p.is_dir())
|
|
||||||
.cloned();
|
|
||||||
// Pack file filters: `.is_file()` keeps the loader's view
|
|
||||||
// consistent with the catalog loader, which skips missing packs
|
|
||||||
// silently. An existing but non-file path (e.g. a directory
|
|
||||||
// named `prompts.toml`) is also elided here and will surface
|
|
||||||
// only when a manifest pack explicitly references it.
|
|
||||||
let user_pack = self
|
|
||||||
.user_pack_file
|
|
||||||
.as_ref()
|
|
||||||
.filter(|p| p.is_file())
|
|
||||||
.cloned();
|
|
||||||
let project_pack = self
|
|
||||||
.project_pack_file
|
|
||||||
.as_ref()
|
|
||||||
.filter(|p| p.is_file())
|
|
||||||
.cloned();
|
|
||||||
PromptLoader::new(user, project).with_pack_files(user_pack, project_pack)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Merge all installed layers, convert the result to a validated
|
|
||||||
/// [`PodManifest`], and return it together with a [`PromptLoader`]
|
|
||||||
/// that reflects the user / project prompt directories. The loader
|
|
||||||
/// feeds `{% include "name" %}` references in the Pod's system
|
|
||||||
/// prompt template.
|
|
||||||
///
|
|
||||||
/// Each layer is resolved to absolute paths against its own base
|
|
||||||
/// (see module docs) **before** merge, so scope rules and
|
|
||||||
/// `api_key_file` paths from different layers do not accidentally
|
|
||||||
/// inherit another layer's base.
|
|
||||||
///
|
|
||||||
/// The base layer is [`PodManifestConfig::builtin_defaults`] so
|
|
||||||
/// every per-field default flows through a single source of truth
|
|
||||||
/// (see [`manifest::defaults`]).
|
|
||||||
pub fn resolve(self) -> Result<(PodManifest, PromptLoader), FactoryError> {
|
|
||||||
let loader = self.build_prompt_loader();
|
|
||||||
let merged = PodManifestConfig::builtin_defaults();
|
|
||||||
let merged = match self.user {
|
|
||||||
Some((user, base)) => merged.merge(user.resolve_paths(&base)),
|
|
||||||
None => merged,
|
|
||||||
};
|
|
||||||
let merged = match self.project {
|
|
||||||
Some((project, base)) => merged.merge(project.resolve_paths(&base)),
|
|
||||||
None => merged,
|
|
||||||
};
|
|
||||||
let merged = match self.overlay {
|
|
||||||
Some(overlay) => merged.merge(overlay),
|
|
||||||
None => merged,
|
|
||||||
};
|
|
||||||
let manifest = PodManifest::try_from(merged).map_err(FactoryError::Resolve)?;
|
|
||||||
Ok((manifest, loader))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn manifest_base(path: &Path) -> Result<PathBuf, FactoryError> {
|
|
||||||
let parent = path.parent().ok_or_else(|| FactoryError::Io {
|
|
||||||
path: path.to_path_buf(),
|
|
||||||
source: std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidInput,
|
|
||||||
"manifest path has no parent directory",
|
|
||||||
),
|
|
||||||
})?;
|
|
||||||
// Absolutise against cwd so later path joins produce absolute
|
|
||||||
// results regardless of whether the caller passed a relative
|
|
||||||
// manifest path.
|
|
||||||
if parent.is_absolute() {
|
|
||||||
Ok(parent.to_path_buf())
|
|
||||||
} else {
|
|
||||||
let cwd = std::env::current_dir().map_err(|source| FactoryError::Io {
|
|
||||||
path: PathBuf::from("."),
|
|
||||||
source,
|
|
||||||
})?;
|
|
||||||
Ok(cwd.join(parent))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_and_merge_overlay(
|
|
||||||
existing: Option<PodManifestConfig>,
|
|
||||||
incoming: PodManifestConfig,
|
|
||||||
) -> Result<PodManifestConfig, FactoryError> {
|
|
||||||
let cwd = std::env::current_dir().map_err(|source| FactoryError::Io {
|
|
||||||
path: PathBuf::from("."),
|
|
||||||
source,
|
|
||||||
})?;
|
|
||||||
let resolved = incoming.resolve_paths(&cwd);
|
|
||||||
Ok(match existing {
|
|
||||||
Some(prev) => prev.merge(resolved),
|
|
||||||
None => resolved,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
fn write(path: &Path, contents: &str) {
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
std::fs::create_dir_all(parent).unwrap();
|
|
||||||
}
|
|
||||||
std::fs::write(path, contents).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_overlay_only() {
|
|
||||||
let tmp = TempDir::new().unwrap();
|
|
||||||
let pwd = tmp.path().canonicalize().unwrap();
|
|
||||||
let overlay = format!(
|
|
||||||
r#"
|
|
||||||
[pod]
|
|
||||||
name = "solo"
|
|
||||||
|
|
||||||
[model]
|
|
||||||
scheme = "anthropic"
|
|
||||||
model_id = "claude-sonnet-4-20250514"
|
|
||||||
|
|
||||||
[[scope.allow]]
|
|
||||||
target = "{pwd}"
|
|
||||||
permission = "write"
|
|
||||||
"#,
|
|
||||||
pwd = pwd.display()
|
|
||||||
);
|
|
||||||
let manifest = PodFactory::new()
|
|
||||||
.with_overlay_toml(&overlay)
|
|
||||||
.unwrap()
|
|
||||||
.resolve()
|
|
||||||
.unwrap();
|
|
||||||
let manifest = manifest.0;
|
|
||||||
assert_eq!(manifest.pod.name, "solo");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn overlay_stacking_merges_in_place() {
|
|
||||||
let tmp = TempDir::new().unwrap();
|
|
||||||
let pwd = tmp.path().canonicalize().unwrap();
|
|
||||||
let user_cfg = PodManifestConfig::from_toml(&format!(
|
|
||||||
r#"
|
|
||||||
[model]
|
|
||||||
scheme = "anthropic"
|
|
||||||
model_id = "user-model"
|
|
||||||
|
|
||||||
[[scope.allow]]
|
|
||||||
target = "{pwd}"
|
|
||||||
permission = "read"
|
|
||||||
"#,
|
|
||||||
pwd = pwd.display()
|
|
||||||
))
|
|
||||||
.unwrap();
|
|
||||||
let project_cfg = PodManifestConfig::from_toml(&format!(
|
|
||||||
r#"
|
|
||||||
[model]
|
|
||||||
model_id = "project-model"
|
|
||||||
|
|
||||||
[[scope.allow]]
|
|
||||||
target = "{pwd}"
|
|
||||||
permission = "write"
|
|
||||||
"#,
|
|
||||||
pwd = pwd.display()
|
|
||||||
))
|
|
||||||
.unwrap();
|
|
||||||
let overlay_cfg = PodManifestConfig::from_toml(
|
|
||||||
r#"
|
|
||||||
[pod]
|
|
||||||
name = "overlay-name"
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let (manifest, _loader) = PodFactory::new()
|
|
||||||
.with_overlay_config(user_cfg)
|
|
||||||
.unwrap()
|
|
||||||
.with_overlay_config(project_cfg)
|
|
||||||
.unwrap()
|
|
||||||
.with_overlay_config(overlay_cfg)
|
|
||||||
.unwrap()
|
|
||||||
.resolve()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Note: stacking via with_overlay_config merges into one
|
|
||||||
// overlay layer so later calls win. This also exercises the
|
|
||||||
// scope union across layers (two allow rules).
|
|
||||||
assert_eq!(manifest.pod.name, "overlay-name");
|
|
||||||
assert_eq!(manifest.model.model_id.as_deref(), Some("project-model"));
|
|
||||||
assert_eq!(manifest.scope.allow.len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cascade_priority_layer_ordering() {
|
|
||||||
let tmp = TempDir::new().unwrap();
|
|
||||||
let pwd = tmp.path().canonicalize().unwrap();
|
|
||||||
|
|
||||||
// Simulate distinct user / project / overlay layers by using
|
|
||||||
// the dedicated slots on the factory.
|
|
||||||
let user = tmp.path().join("user.toml");
|
|
||||||
write(
|
|
||||||
&user,
|
|
||||||
&format!(
|
|
||||||
r#"
|
|
||||||
[pod]
|
|
||||||
name = "from-user"
|
|
||||||
|
|
||||||
[model]
|
|
||||||
scheme = "anthropic"
|
|
||||||
model_id = "user-model"
|
|
||||||
|
|
||||||
[[scope.allow]]
|
|
||||||
target = "{pwd}"
|
|
||||||
permission = "write"
|
|
||||||
"#,
|
|
||||||
pwd = pwd.display()
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
let project_root = tmp.path().join("proj");
|
|
||||||
let project_manifest = project_root.join(".insomnia").join("manifest.toml");
|
|
||||||
write(
|
|
||||||
&project_manifest,
|
|
||||||
r#"
|
|
||||||
[model]
|
|
||||||
model_id = "project-model"
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
|
|
||||||
let (manifest, _loader) = PodFactory::new()
|
|
||||||
.with_user_manifest(&user)
|
|
||||||
.unwrap()
|
|
||||||
.with_project_manifest_from(&project_root)
|
|
||||||
.unwrap()
|
|
||||||
.resolve()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// project layer overrides user layer on model.model_id
|
|
||||||
assert_eq!(manifest.model.model_id.as_deref(), Some("project-model"));
|
|
||||||
// user layer provides the rest
|
|
||||||
assert_eq!(manifest.pod.name, "from-user");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn project_manifest_walks_up_from_nested_dir() {
|
|
||||||
let tmp = TempDir::new().unwrap();
|
|
||||||
let root = tmp.path().canonicalize().unwrap();
|
|
||||||
let project_manifest = root.join(".insomnia").join("manifest.toml");
|
|
||||||
write(
|
|
||||||
&project_manifest,
|
|
||||||
&format!(
|
|
||||||
r#"
|
|
||||||
[pod]
|
|
||||||
name = "walked-up"
|
|
||||||
|
|
||||||
[model]
|
|
||||||
scheme = "anthropic"
|
|
||||||
model_id = "claude-sonnet-4-20250514"
|
|
||||||
|
|
||||||
[[scope.allow]]
|
|
||||||
target = "{root}"
|
|
||||||
permission = "write"
|
|
||||||
"#,
|
|
||||||
root = root.display()
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
let nested = root.join("a").join("b").join("c");
|
|
||||||
std::fs::create_dir_all(&nested).unwrap();
|
|
||||||
|
|
||||||
let manifest = PodFactory::new()
|
|
||||||
.with_project_manifest_from(&nested)
|
|
||||||
.unwrap()
|
|
||||||
.resolve()
|
|
||||||
.unwrap();
|
|
||||||
let manifest = manifest.0;
|
|
||||||
assert_eq!(manifest.pod.name, "walked-up");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn missing_project_root_is_ok() {
|
|
||||||
let tmp = TempDir::new().unwrap();
|
|
||||||
let pwd = tmp.path().canonicalize().unwrap();
|
|
||||||
let overlay = format!(
|
|
||||||
r#"
|
|
||||||
[pod]
|
|
||||||
name = "standalone"
|
|
||||||
|
|
||||||
[model]
|
|
||||||
scheme = "anthropic"
|
|
||||||
model_id = "m"
|
|
||||||
|
|
||||||
[[scope.allow]]
|
|
||||||
target = "{pwd}"
|
|
||||||
permission = "write"
|
|
||||||
"#,
|
|
||||||
pwd = pwd.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
// The temp dir has no .insomnia/ — walking up should skip the
|
|
||||||
// project layer silently.
|
|
||||||
let manifest = PodFactory::new()
|
|
||||||
.with_project_manifest_from(&pwd)
|
|
||||||
.unwrap()
|
|
||||||
.with_overlay_toml(&overlay)
|
|
||||||
.unwrap()
|
|
||||||
.resolve()
|
|
||||||
.unwrap();
|
|
||||||
let manifest = manifest.0;
|
|
||||||
assert_eq!(manifest.pod.name, "standalone");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn user_manifest_relative_paths_resolve_against_its_directory() {
|
|
||||||
// user manifest at <tmp>/cfg/manifest.toml with a relative
|
|
||||||
// scope target `./workspace` must resolve to <tmp>/cfg/workspace.
|
|
||||||
let tmp = TempDir::new().unwrap();
|
|
||||||
let root = tmp.path().canonicalize().unwrap();
|
|
||||||
let cfg_dir = root.join("cfg");
|
|
||||||
std::fs::create_dir_all(&cfg_dir).unwrap();
|
|
||||||
let workspace = cfg_dir.join("workspace");
|
|
||||||
std::fs::create_dir_all(&workspace).unwrap();
|
|
||||||
|
|
||||||
let user = cfg_dir.join("manifest.toml");
|
|
||||||
write(
|
|
||||||
&user,
|
|
||||||
r#"
|
|
||||||
[pod]
|
|
||||||
name = "rel-user"
|
|
||||||
|
|
||||||
[model]
|
|
||||||
scheme = "anthropic"
|
|
||||||
model_id = "m"
|
|
||||||
|
|
||||||
[[scope.allow]]
|
|
||||||
target = "./workspace"
|
|
||||||
permission = "write"
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
|
|
||||||
let (manifest, _loader) = PodFactory::new()
|
|
||||||
.with_user_manifest(&user)
|
|
||||||
.unwrap()
|
|
||||||
.resolve()
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(manifest.scope.allow[0].target, workspace);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn project_manifest_relative_paths_resolve_against_project_root() {
|
|
||||||
// `.insomnia/manifest.toml` is the marker for the project, but
|
|
||||||
// the intuitive base for its relative paths is the project
|
|
||||||
// root (the parent of `.insomnia/`) — `target = "."` in a
|
|
||||||
// project manifest should cover the whole workspace, not the
|
|
||||||
// `.insomnia/` subdir.
|
|
||||||
let tmp = TempDir::new().unwrap();
|
|
||||||
let root = tmp.path().canonicalize().unwrap();
|
|
||||||
let insomnia_dir = root.join(".insomnia");
|
|
||||||
std::fs::create_dir_all(&insomnia_dir).unwrap();
|
|
||||||
let project_manifest = insomnia_dir.join("manifest.toml");
|
|
||||||
write(
|
|
||||||
&project_manifest,
|
|
||||||
r#"
|
|
||||||
[pod]
|
|
||||||
name = "rel-project"
|
|
||||||
|
|
||||||
[model]
|
|
||||||
scheme = "anthropic"
|
|
||||||
model_id = "m"
|
|
||||||
|
|
||||||
[[scope.allow]]
|
|
||||||
target = "."
|
|
||||||
permission = "read"
|
|
||||||
|
|
||||||
[[scope.allow]]
|
|
||||||
target = "src"
|
|
||||||
permission = "write"
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
|
|
||||||
let (manifest, _loader) = PodFactory::new()
|
|
||||||
.with_project_manifest_from(&root)
|
|
||||||
.unwrap()
|
|
||||||
.resolve()
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(manifest.scope.allow[0].target, root);
|
|
||||||
assert_eq!(manifest.scope.allow[1].target, root.join("src"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_produces_loader_with_workspace_prompts_dir() {
|
|
||||||
use crate::prompt::system::{SystemPromptContext, SystemPromptTemplate};
|
|
||||||
use manifest::{Permission, Scope, ScopeConfig, ScopeRule};
|
|
||||||
|
|
||||||
let tmp = TempDir::new().unwrap();
|
|
||||||
let root = tmp.path().canonicalize().unwrap();
|
|
||||||
// .insomnia/manifest.toml and .insomnia/prompts/local.md
|
|
||||||
let manifest_path = root.join(".insomnia").join("manifest.toml");
|
|
||||||
write(
|
|
||||||
&manifest_path,
|
|
||||||
&format!(
|
|
||||||
r#"
|
|
||||||
[pod]
|
|
||||||
name = "factory-pod"
|
|
||||||
|
|
||||||
[model]
|
|
||||||
scheme = "anthropic"
|
|
||||||
model_id = "m"
|
|
||||||
|
|
||||||
[[scope.allow]]
|
|
||||||
target = "{root}"
|
|
||||||
permission = "write"
|
|
||||||
"#,
|
|
||||||
root = root.display()
|
|
||||||
),
|
|
||||||
);
|
|
||||||
let workspace_prompts_dir = root.join(".insomnia").join("prompts");
|
|
||||||
std::fs::create_dir_all(&workspace_prompts_dir).unwrap();
|
|
||||||
std::fs::write(
|
|
||||||
workspace_prompts_dir.join("local.md"),
|
|
||||||
"WORKSPACE-BODY from {{ cwd }}",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let (_manifest, loader) = PodFactory::new()
|
|
||||||
.with_project_manifest_from(&root)
|
|
||||||
.unwrap()
|
|
||||||
.resolve()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// The workspace prompt must be reachable via $workspace/local.
|
|
||||||
let tmpl = SystemPromptTemplate::parse("$workspace/local", loader).unwrap();
|
|
||||||
let scope_cfg = ScopeConfig {
|
|
||||||
allow: vec![ScopeRule {
|
|
||||||
target: root.clone(),
|
|
||||||
permission: Permission::Write,
|
|
||||||
recursive: true,
|
|
||||||
}],
|
|
||||||
deny: Vec::new(),
|
|
||||||
};
|
|
||||||
let scope = Scope::from_config(&scope_cfg).unwrap();
|
|
||||||
let catalog = crate::prompt::catalog::PromptCatalog::builtins_only().unwrap();
|
|
||||||
let ctx = SystemPromptContext {
|
|
||||||
now: chrono::Utc::now(),
|
|
||||||
cwd: &root,
|
|
||||||
language: manifest::defaults::WORKER_LANGUAGE,
|
|
||||||
scope: &scope,
|
|
||||||
tool_names: Vec::new(),
|
|
||||||
agents_md: None,
|
|
||||||
resident_summary: None,
|
|
||||||
resident_knowledge: None,
|
|
||||||
resident_workflows: None,
|
|
||||||
prompts: &catalog,
|
|
||||||
};
|
|
||||||
let rendered = tmpl.render(&ctx).unwrap();
|
|
||||||
assert!(
|
|
||||||
rendered.starts_with("WORKSPACE-BODY"),
|
|
||||||
"expected workspace body, got: {rendered}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_fails_on_missing_required_field() {
|
|
||||||
let tmp = TempDir::new().unwrap();
|
|
||||||
let pwd = tmp.path().canonicalize().unwrap();
|
|
||||||
// pod.name missing — resolver must reject.
|
|
||||||
let overlay = format!(
|
|
||||||
r#"
|
|
||||||
[model]
|
|
||||||
scheme = "anthropic"
|
|
||||||
model_id = "m"
|
|
||||||
|
|
||||||
[[scope.allow]]
|
|
||||||
target = "{pwd}"
|
|
||||||
permission = "write"
|
|
||||||
"#,
|
|
||||||
pwd = pwd.display()
|
|
||||||
);
|
|
||||||
let err = PodFactory::new()
|
|
||||||
.with_overlay_toml(&overlay)
|
|
||||||
.unwrap()
|
|
||||||
.resolve()
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(matches!(err, FactoryError::Resolve(_)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -11,14 +11,12 @@ pub mod shared_state;
|
||||||
pub mod spawn;
|
pub mod spawn;
|
||||||
pub mod workflow;
|
pub mod workflow;
|
||||||
|
|
||||||
mod factory;
|
|
||||||
mod interrupt_prep;
|
mod interrupt_prep;
|
||||||
mod permission;
|
mod permission;
|
||||||
mod pod;
|
mod pod;
|
||||||
|
|
||||||
pub use compact::token_counter::{EstimateSource, SplitPoint, TokenEstimate};
|
pub use compact::token_counter::{EstimateSource, SplitPoint, TokenEstimate};
|
||||||
pub use controller::{PodController, PodHandle, ShutdownReceiver};
|
pub use controller::{PodController, PodHandle, ShutdownReceiver};
|
||||||
pub use factory::{FactoryError, PodFactory};
|
|
||||||
pub use hook::{Hook, HookEventKind, HookRegistryBuilder};
|
pub use hook::{Hook, HookEventKind, HookRegistryBuilder};
|
||||||
pub use ipc::alerter::Alerter;
|
pub use ipc::alerter::Alerter;
|
||||||
pub use ipc::server::SocketServer;
|
pub use ipc::server::SocketServer;
|
||||||
|
|
|
||||||
|
|
@ -3911,8 +3911,7 @@ where
|
||||||
|
|
||||||
/// Restore a Pod from an existing session log.
|
/// Restore a Pod from an existing session log.
|
||||||
///
|
///
|
||||||
/// Resolves the manifest cascade exactly like [`Self::from_manifest`]
|
/// Uses the resolved manifest supplied by the caller, seeds a
|
||||||
/// (pwd / scope / pod-registry / client / prompt catalog), seeds a
|
|
||||||
/// fresh Worker from the source session's `RestoredState`, and
|
/// fresh Worker from the source session's `RestoredState`, and
|
||||||
/// reuses the same `segment_id` so subsequent turns append to the
|
/// reuses the same `segment_id` so subsequent turns append to the
|
||||||
/// source jsonl as a continuation of the same conversation.
|
/// source jsonl as a continuation of the same conversation.
|
||||||
|
|
@ -4634,7 +4633,7 @@ pub enum PodError {
|
||||||
/// Bundle of resources that every high-level Pod constructor needs:
|
/// Bundle of resources that every high-level Pod constructor needs:
|
||||||
/// pwd, scope, an LLM client, the prompt catalog, and (optionally) a
|
/// pwd, scope, an LLM client, the prompt catalog, and (optionally) a
|
||||||
/// parsed system-prompt template. Built once by [`prepare_pod_common`]
|
/// parsed system-prompt template. Built once by [`prepare_pod_common`]
|
||||||
/// from the manifest cascade and then split into Pod fields.
|
/// from the resolved manifest and then split into Pod fields.
|
||||||
struct PodCommon {
|
struct PodCommon {
|
||||||
pwd: PathBuf,
|
pwd: PathBuf,
|
||||||
scope: Scope,
|
scope: Scope,
|
||||||
|
|
@ -4711,7 +4710,7 @@ fn delegated_write_rule_to_deny(rule: PodSpawnedScopeRule) -> Option<ScopeRule>
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve pwd / scope / LLM client / prompt catalog from a validated
|
/// Resolve pwd / scope / LLM client / prompt catalog from a validated
|
||||||
/// manifest cascade. Used by `from_manifest`, `from_manifest_spawned`,
|
/// manifest. Used by `from_manifest`, `from_manifest_spawned`,
|
||||||
/// and `restore_from_manifest` so they share one definition of "what
|
/// and `restore_from_manifest` so they share one definition of "what
|
||||||
/// pieces fall out of a manifest".
|
/// pieces fall out of a manifest".
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,10 @@
|
||||||
//!
|
//!
|
||||||
//! 1. **builtin** — `resources/prompts/internal.toml`, baked into the
|
//! 1. **builtin** — `resources/prompts/internal.toml`, baked into the
|
||||||
//! binary. Must cover every [`PodPrompt`] variant (build-time check).
|
//! binary. Must cover every [`PodPrompt`] variant (build-time check).
|
||||||
//! 2. **user** — `<user_manifest_dir>/prompts.toml`, auto-discovered by
|
//! 2. **user** — `<config_dir>/prompts.toml`, when a caller supplies it.
|
||||||
//! [`PodFactory`]. Optional.
|
|
||||||
//! 3. **workspace** — `<project>/.insomnia/prompts.toml`, auto-discovered.
|
|
||||||
//! Optional.
|
//! Optional.
|
||||||
|
//! 3. **workspace** — `<project>/.insomnia/prompts.toml`, when a caller
|
||||||
|
//! supplies it. Optional.
|
||||||
//! 4. **manifest pack** — `manifest.pod.prompt_pack`, an explicit path
|
//! 4. **manifest pack** — `manifest.pod.prompt_pack`, an explicit path
|
||||||
//! per-Pod. Optional.
|
//! per-Pod. Optional.
|
||||||
//!
|
//!
|
||||||
|
|
@ -270,7 +270,7 @@ impl PromptCatalog {
|
||||||
/// - Layer 2 (user): `loader.user_pack_file()` if present.
|
/// - Layer 2 (user): `loader.user_pack_file()` if present.
|
||||||
/// - Layer 3 (workspace): `loader.workspace_pack_file()` if present.
|
/// - Layer 3 (workspace): `loader.workspace_pack_file()` if present.
|
||||||
/// - Layer 4 (manifest): `manifest_pack` as an absolute filesystem
|
/// - Layer 4 (manifest): `manifest_pack` as an absolute filesystem
|
||||||
/// path (pre-resolved by the manifest cascade).
|
/// path (pre-resolved by profile/manifest resolution).
|
||||||
pub fn load(
|
pub fn load(
|
||||||
loader: &PromptLoader,
|
loader: &PromptLoader,
|
||||||
manifest_pack: Option<&Path>,
|
manifest_pack: Option<&Path>,
|
||||||
|
|
|
||||||
|
|
@ -138,9 +138,8 @@ impl PromptLoader {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Override the auto-discovered pack file paths. Used by
|
/// Override pack file paths supplied by the caller's profile/manifest
|
||||||
/// [`crate::PodFactory`] to surface `<user_manifest_dir>/prompts.toml`
|
/// resolution context.
|
||||||
/// and `<project>/.insomnia/prompts.toml`.
|
|
||||||
pub fn with_pack_files(
|
pub fn with_pack_files(
|
||||||
mut self,
|
mut self,
|
||||||
user_pack_file: Option<PathBuf>,
|
user_pack_file: Option<PathBuf>,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user