yoi/crates/pod/src/factory.rs

687 lines
22 KiB
Rust

//! 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(_)));
}
}