diff --git a/lib/components/fabro-manifest/src/lib.rs b/lib/components/fabro-manifest/src/lib.rs index 762265d13..eff4ff2fc 100644 --- a/lib/components/fabro-manifest/src/lib.rs +++ b/lib/components/fabro-manifest/src/lib.rs @@ -3,10 +3,10 @@ reason = "CLI manifest builder: sync file I/O building install manifests" )] -mod workflow_bundle; +mod workflow_bundler; use std::collections::HashMap; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use anyhow::{Context, Result, anyhow}; use fabro_api::types; @@ -21,17 +21,13 @@ use fabro_graphviz::graph::AttrValue; use fabro_graphviz::parser; use fabro_types::settings::interp::InterpString; use fabro_types::settings::run::{ApprovalMode, ResolvedGoalSource, ResolvedRunGoal, RunMode}; -use fabro_types::{DirtyStatus, GitContext, WorkflowSettings}; +use fabro_types::{DirtyStatus, GitContext, ManifestPath, WorkflowSettings}; use fabro_workflow::git::{ GitSyncStatus, branch_needs_push, head_sha, push_branch_noninteractive, sync_status, }; use fabro_workflow::static_reference::ReferenceKind; -use crate::workflow_bundle::{ - CollectWorkflowBundleInput, CollectedDocument, CollectedFileReferenceType, - CollectedSourceInput, CollectedWorkflowBundle, manifest_path_from_absolute, - normalize_absolute_path, -}; +use crate::workflow_bundler::WorkflowBundler; #[derive(Debug, Default)] pub struct ManifestBuildInput { @@ -138,13 +134,14 @@ pub fn build_run_manifest(input: ManifestBuildInput) -> Result { } let project_config = discover_project_config(&root_location.dir)?; let project_config_source = project_config - .as_deref() - .map(read_source_input) + .as_ref() + .map(|path| { + let source = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read {}", path.display()))?; + let manifest_path = manifest_path_from_absolute(path, &input.cwd)?; + Ok::<_, anyhow::Error>((path.clone(), manifest_path, source)) + }) .transpose()?; - let user_settings_path = input - .user_settings_path - .as_deref() - .filter(|path| path.is_file()); let mut workflow_settings_builder = WorkflowSettingsBuilder::new() .server_manifest_defaults(RunLayer::default(), input.environment_defaults.clone()); @@ -160,7 +157,11 @@ pub fn build_run_manifest(input: ManifestBuildInput) -> Result { if let Some(path) = project_config.as_ref() { workflow_settings_builder = workflow_settings_builder.project_file(path)?; } - if let Some(path) = user_settings_path { + if let Some(path) = input + .user_settings_path + .as_ref() + .filter(|path| path.is_file()) + { workflow_settings_builder = workflow_settings_builder.user_file(path)?; } let mut workflow_settings = workflow_settings_builder @@ -168,15 +169,35 @@ pub fn build_run_manifest(input: ManifestBuildInput) -> Result { .context("failed to resolve manifest settings")?; workflow_settings.run.inputs.extend(input.input_overrides); let target_path = root_location.graph.clone(); - let user_config_source = user_settings_path.map(read_source_input).transpose()?; - let collected = workflow_bundle::collect_workflow_bundle(CollectWorkflowBundleInput { - cwd: &input.cwd, - root_location, - inputs: &workflow_settings.run.inputs, - project_config: project_config_source, - user_config: user_config_source, - })?; - let assembled = assemble_current_manifest(collected, &input.cwd)?; + let target_manifest_path = manifest_path_from_absolute(&target_path, &input.cwd)?; + let target_key = target_manifest_path.to_string(); + let project_config_input = project_config_source + .as_ref() + .map(|(_, path, source)| (path, source.as_str())); + let workflows = WorkflowBundler::new(&input.cwd, &workflow_settings.run.inputs) + .bundle(&root_location, project_config_input)?; + let root_source = workflows + .get(&target_key) + .map(|workflow| workflow.source.clone()) + .ok_or_else(|| anyhow!("root workflow missing from manifest bundle"))?; + + let mut configs = Vec::new(); + if let Some((path, _, source)) = project_config_source { + configs.push(types::ManifestConfig { + path: Some(path.display().to_string()), + source: Some(source), + type_: types::ManifestConfigType::Project, + }); + } + if let Some(path) = input.user_settings_path.filter(|path| path.is_file()) { + let source = std::fs::read_to_string(&path) + .with_context(|| format!("Failed to read {}", path.display()))?; + configs.push(types::ManifestConfig { + path: Some(path.display().to_string()), + source: Some(source), + type_: types::ManifestConfigType::User, + }); + } let working_directory = project::resolve_working_directory_from_run(&workflow_settings.run, &input.cwd); @@ -184,7 +205,7 @@ pub fn build_run_manifest(input: ManifestBuildInput) -> Result { let goal = resolve_manifest_goal( input.run_overrides.as_ref(), &workflow_settings, - &assembled.root_source, + &root_source, &target_path, &working_directory, )?; @@ -196,118 +217,20 @@ pub fn build_run_manifest(input: ManifestBuildInput) -> Result { Ok(BuiltManifest { manifest: types::RunManifest { args, + configs, cwd: input.cwd.display().to_string(), git, goal, parent_id: None, title: None, - target: types::ManifestTarget { - path: assembled.target_key, - }, + target: types::ManifestTarget { path: target_key }, version: 1, - workflows: assembled.workflows, - configs: assembled.configs, + workflows, }, target_path, }) } -struct AssembledCurrentManifest { - target_key: String, - root_source: String, - workflows: HashMap, - configs: Vec, -} - -fn assemble_current_manifest( - collected: CollectedWorkflowBundle, - cwd: &Path, -) -> Result { - let root = collected - .workflows - .get(&collected.entrypoint) - .ok_or_else(|| anyhow!("root workflow missing from collected workflow bundle"))?; - let target_key = manifest_path_from_absolute(&root.graph.access_path, cwd)?.to_string(); - let root_source = root.graph.source.clone(); - - let mut workflows = HashMap::new(); - for workflow in collected.workflows.into_values() { - let graph_key = manifest_path_from_absolute(&workflow.graph.access_path, cwd)?.to_string(); - let config = workflow - .config - .map(|config| { - Ok::<_, anyhow::Error>(types::ManifestWorkflowConfig { - path: manifest_path_from_absolute(&config.access_path, cwd)?.to_string(), - source: config.source, - }) - }) - .transpose()?; - let mut files = HashMap::new(); - for file in workflow.files.into_values() { - let key = manifest_path_from_absolute(&file.document.access_path, cwd)?.to_string(); - let from = file - .reference - .from_access_path - .as_deref() - .map(|path| manifest_path_from_absolute(path, cwd).map(|path| path.to_string())) - .transpose()?; - let type_ = match file.reference.type_ { - CollectedFileReferenceType::FileInline => types::ManifestFileRefType::FileInline, - CollectedFileReferenceType::Import => types::ManifestFileRefType::Import, - CollectedFileReferenceType::Dockerfile => types::ManifestFileRefType::Dockerfile, - }; - files.insert(key, types::ManifestFileEntry { - content: file.document.source, - ref_: types::ManifestFileRef { - from, - original: file.reference.original, - type_, - }, - }); - } - workflows.insert(graph_key, types::ManifestWorkflow { - source: workflow.graph.source, - config, - files, - }); - } - - let mut configs = Vec::new(); - if let Some(config) = collected.project_config { - configs.push(manifest_config(config, types::ManifestConfigType::Project)); - } - if let Some(config) = collected.user_config { - configs.push(manifest_config(config, types::ManifestConfigType::User)); - } - - Ok(AssembledCurrentManifest { - target_key, - root_source, - workflows, - configs, - }) -} - -fn manifest_config( - document: CollectedDocument, - type_: types::ManifestConfigType, -) -> types::ManifestConfig { - types::ManifestConfig { - path: Some(document.access_path.display().to_string()), - source: Some(document.source), - type_, - } -} - -fn read_source_input(path: &Path) -> Result { - let source = std::fs::read_to_string(path) - .with_context(|| format!("Failed to read {}", path.display()))?; - Ok(CollectedSourceInput { - access_path: path.to_path_buf(), - source, - }) -} - fn resolve_manifest_goal( run_overrides: Option<&RunLayer>, settings: &WorkflowSettings, @@ -473,6 +396,32 @@ fn push_manifest_branch_best_effort( let _ = push_branch_noninteractive(repo_path, "origin", branch); } +fn normalize_absolute_path(base_dir: &Path, reference: &str) -> Option { + let path = Path::new(reference); + if path.is_absolute() || reference.starts_with('~') { + return None; + } + + let mut normalized = PathBuf::new(); + for component in base_dir.join(path).components() { + match component { + Component::CurDir => {} + Component::Normal(part) => normalized.push(part), + Component::ParentDir => { + normalized.pop(); + } + Component::RootDir => normalized.push(Path::new("/")), + Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), + } + } + Some(normalized) +} + +fn manifest_path_from_absolute(path: &Path, cwd: &Path) -> Result { + ManifestPath::from_absolute(path, cwd) + .ok_or_else(|| anyhow!("Failed to compute manifest path for {}", path.display())) +} + pub fn manifest_args_is_empty(args: &types::ManifestArgs) -> bool { args.auto_approve.is_none() && args.dry_run.is_none() @@ -699,19 +648,19 @@ graph = "workflow.fabro" ), ".fabro/workflows/root/prompts/deep.md": file( "deep\n", - ".fabro/workflows/root/workflow.fabro", + ".fabro/workflows/root/prompts/plan.md", ".fabro/workflows/root/prompts/deep.md", "file_inline", ), ".fabro/workflows/root/prompts/helpers.md": file( helpers, - ".fabro/workflows/root/workflow.fabro", + ".fabro/workflows/root/prompts/plan.md", ".fabro/workflows/root/prompts/helpers.md", "file_inline", ), ".fabro/workflows/root/prompts/partial.md": file( "partial\n", - ".fabro/workflows/root/workflow.fabro", + ".fabro/workflows/root/prompts/plan.md", ".fabro/workflows/root/prompts/partial.md", "file_inline", ), @@ -1057,17 +1006,26 @@ graph = "workflow.fabro" .unwrap(); let root = &built.manifest.workflows[".fabro/workflows/demo/workflow.fabro"]; - assert!( - root.files - .contains_key(".fabro/workflows/demo/prompts/goal.tpl.md") + assert_eq!( + root.files[".fabro/workflows/demo/prompts/goal.tpl.md"] + .ref_ + .from + .as_deref(), + Some(".fabro/workflows/demo/prompts/goal.md") ); - assert!( - root.files - .contains_key(".fabro/workflows/demo/prompts/plan.tpl.md") + assert_eq!( + root.files[".fabro/workflows/demo/prompts/plan.tpl.md"] + .ref_ + .from + .as_deref(), + Some(".fabro/workflows/demo/prompts/plan.md") ); - assert!( - root.files - .contains_key(".fabro/workflows/demo/inline.tpl.md") + assert_eq!( + root.files[".fabro/workflows/demo/inline.tpl.md"] + .ref_ + .from + .as_deref(), + Some(".fabro/workflows/demo/workflow.fabro") ); } @@ -1166,8 +1124,9 @@ graph = "workflow.fabro" .unwrap_err(); assert!( - err.chain() - .any(|cause| cause.to_string().contains("dynamic template dependency")), + err.chain().any(|cause| cause + .downcast_ref::() + .is_some()), "unexpected error: {err:#}" ); } diff --git a/lib/components/fabro-manifest/src/workflow_bundle.rs b/lib/components/fabro-manifest/src/workflow_bundle.rs deleted file mode 100644 index 6c7d5c0d1..000000000 --- a/lib/components/fabro-manifest/src/workflow_bundle.rs +++ /dev/null @@ -1,1628 +0,0 @@ -use std::collections::{BTreeMap, HashMap, HashSet}; -use std::fmt; -use std::path::{Component, Path, PathBuf}; -use std::sync::Arc; - -use anyhow::{Context as _, Result, anyhow, bail}; -use fabro_config::project::WorkflowLocation; -use fabro_config::{EnvironmentDockerfileLayer, EnvironmentImageLayer, SettingsLayer}; -use fabro_graphviz::graph::AttrValue; -use fabro_graphviz::parser; -use fabro_template::{ - BundleTemplateStore, FilesystemTemplateStore, RecordingTemplateStore, TemplateContext, - TemplateDependencyClosure, TemplateRenderMode, TemplateSource, - discover_static_dependency_closure, render_source, -}; -use fabro_types::ManifestPath; -use fabro_workflow::static_reference::{ - AttributeScope, ReferenceKind, reference_kind_for_attribute, -}; - -pub(super) struct CollectWorkflowBundleInput<'a> { - pub(super) cwd: &'a Path, - pub(super) root_location: WorkflowLocation, - pub(super) inputs: &'a HashMap, - pub(super) project_config: Option, - pub(super) user_config: Option, -} - -pub(super) struct CollectedSourceInput { - pub(super) access_path: PathBuf, - pub(super) source: String, -} - -#[derive(Clone, Debug)] -pub(super) struct CollectedWorkflowBundle { - pub(super) entrypoint: CollectedPath, - pub(super) workflows: BTreeMap, - pub(super) project_config: Option, - pub(super) user_config: Option, -} - -#[derive(Clone, Debug)] -pub(super) struct CollectedWorkflow { - pub(super) graph: CollectedDocument, - pub(super) config: Option, - pub(super) files: BTreeMap, -} - -#[derive(Clone, Debug)] -pub(super) struct CollectedDocument { - pub(super) access_path: PathBuf, - pub(super) path: CollectedPath, - pub(super) source: String, -} - -#[derive(Clone, Debug)] -pub(super) struct CollectedFile { - pub(super) document: CollectedDocument, - pub(super) reference: CollectedFileReference, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(super) enum CollectedFileReferenceType { - FileInline, - Import, - Dockerfile, -} - -#[derive(Clone, Debug)] -pub(super) struct CollectedFileReference { - pub(super) type_: CollectedFileReferenceType, - pub(super) original: String, - pub(super) from_access_path: Option, -} - -/// A canonical virtual coordinate inside one collected workflow bundle. -#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub(super) struct CollectedPath(String); - -impl CollectedPath { - fn try_new(path: impl AsRef) -> Result { - let path = path.as_ref(); - let value = path - .to_str() - .ok_or_else(|| anyhow!("collected path is not valid UTF-8"))?; - - if value.is_empty() { - bail!("collected path must not be empty"); - } - if path.is_absolute() { - bail!("collected path must be relative: {value}"); - } - if value.contains('\\') { - bail!("collected path must use forward slashes: {value}"); - } - if value.chars().any(char::is_control) { - bail!("collected path contains a control character"); - } - let bytes = value.as_bytes(); - if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' { - bail!("collected path must not use a Windows drive prefix: {value}"); - } - if value - .split('/') - .any(|segment| segment.is_empty() || segment == "." || segment == "..") - { - bail!("collected path contains an empty, dot, or parent component: {value}"); - } - - Ok(Self(value.to_owned())) - } - - pub(super) fn as_str(&self) -> &str { - &self.0 - } - - #[cfg(test)] - fn as_path(&self) -> &Path { - Path::new(&self.0) - } -} - -impl fmt::Display for CollectedPath { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.as_str()) - } -} - -#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -enum ComponentRole { - Workflow, - ProjectConfig, - UserConfig, -} - -impl ComponentRole { - const fn label(self) -> &'static str { - match self { - Self::Workflow => "workflow", - Self::ProjectConfig => "project_config", - Self::UserConfig => "user_config", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -struct DocumentId(usize); - -#[derive(Clone, Debug)] -struct DocumentRecord { - access_path: PathBuf, - provisional_path: PathBuf, - component: ComponentRole, - source: String, -} - -#[derive(Clone, Debug)] -struct FileReferenceRecord { - type_: CollectedFileReferenceType, - original: String, - from_document: Option, -} - -#[derive(Clone, Debug)] -struct FileRecord { - document: DocumentId, - reference: FileReferenceRecord, -} - -#[derive(Clone, Debug)] -struct WorkflowRecord { - graph: DocumentId, - config: Option, - files: BTreeMap, -} - -struct WorkflowBundler<'a> { - cwd: PathBuf, - inputs: &'a HashMap, - documents: Vec, - document_ids: HashMap<(PathBuf, ComponentRole, PathBuf), DocumentId>, - workflows: BTreeMap, - visited_workflows: HashMap, -} - -impl<'a> WorkflowBundler<'a> { - fn try_new(cwd: &Path, inputs: &'a HashMap) -> Result { - Ok(Self { - cwd: normalized_absolute_access_path(cwd)?, - inputs, - documents: Vec::new(), - document_ids: HashMap::new(), - workflows: BTreeMap::new(), - visited_workflows: HashMap::new(), - }) - } - - fn insert_document( - &mut self, - access_path: &Path, - provisional_path: PathBuf, - component: ComponentRole, - source: String, - ) -> DocumentId { - let access_path = lexically_normalize_access_path(access_path); - let key = (access_path.clone(), component, provisional_path.clone()); - if let Some(document) = self.document_ids.get(&key) { - return *document; - } - - let document = DocumentId(self.documents.len()); - self.documents.push(DocumentRecord { - access_path, - provisional_path, - component, - source, - }); - self.document_ids.insert(key, document); - document - } - - fn document(&self, document: DocumentId) -> &DocumentRecord { - &self.documents[document.0] - } - - fn collect_workflow_location( - &mut self, - location: &WorkflowLocation, - provisional_graph_path: PathBuf, - ) -> Result { - let graph_access_path = normalized_absolute_access_path(&location.graph)?; - let graph_manifest_path = manifest_path_from_absolute(&graph_access_path, &self.cwd)?; - let graph_key = graph_manifest_path.to_string(); - if let Some(document) = self.visited_workflows.get(&graph_key) { - return Ok(*document); - } - - let graph_source = std::fs::read_to_string(&graph_access_path) - .with_context(|| format!("Failed to read {}", graph_access_path.display()))?; - let graph = self.insert_document( - &graph_access_path, - provisional_graph_path, - ComponentRole::Workflow, - graph_source, - ); - self.visited_workflows.insert(graph_key.clone(), graph); - - let config = location - .toml - .as_ref() - .map(|config_path| { - let access_path = normalized_absolute_access_path(config_path)?; - let source = std::fs::read_to_string(&access_path) - .with_context(|| format!("Failed to read {}", access_path.display()))?; - let file_name = access_path.file_name().ok_or_else(|| { - anyhow!( - "workflow config has no file name: {}", - access_path.display() - ) - })?; - let provisional_path = virtual_sibling_path( - &self.document(graph).provisional_path, - Path::new(file_name), - )?; - Ok::<_, anyhow::Error>(self.insert_document( - &access_path, - provisional_path, - ComponentRole::Workflow, - source, - )) - }) - .transpose()?; - - let mut workflow = WorkflowRecord { - graph, - config, - files: BTreeMap::new(), - }; - if let Some(config) = config { - self.collect_config_dockerfile(config, &mut workflow.files)?; - } - self.collect_workflow_files(graph, &mut workflow.files, &mut HashSet::new())?; - self.workflows.insert(graph_key, workflow); - - Ok(graph) - } - - fn collect_workflow_entry( - &mut self, - workflow: &Path, - resolve_from: &Path, - provisional_graph_path: PathBuf, - ) -> Result { - let normalized_workflow = if workflow.extension().is_some() && workflow.is_relative() { - normalize_absolute_path(resolve_from, &workflow.to_string_lossy()).ok_or_else(|| { - anyhow!( - "unsupported manifest workflow reference: {}", - workflow.display() - ) - })? - } else { - workflow.to_path_buf() - }; - let location = WorkflowLocation::resolve(&normalized_workflow, resolve_from)?; - self.collect_workflow_location(&location, provisional_graph_path) - } - - fn collect_workflow_files( - &mut self, - graph_document_id: DocumentId, - files: &mut BTreeMap, - visited_imports: &mut HashSet, - ) -> Result<()> { - let graph = parser::parse(&self.document(graph_document_id).source).with_context(|| { - format!( - "Failed to parse {}", - self.document(graph_document_id).access_path.display() - ) - })?; - let graph_access_path = self.document(graph_document_id).access_path.clone(); - let workflow_base_dir = graph_access_path.parent().unwrap_or_else(|| Path::new(".")); - let graph_manifest_path = manifest_path_from_absolute(&graph_access_path, &self.cwd)?; - let workflow_template_root = manifest_parent_or_dot(&graph_manifest_path)?; - - if let Some(goal_reference) = graph.attrs.get("goal").and_then(AttrValue::as_str) { - if let Some(reference) = goal_reference.strip_prefix('@') { - let bundled = self.collect_bundled_file( - files, - workflow_base_dir, - reference, - CollectedFileReferenceType::FileInline, - manifest_attr_reference_kind(AttributeScope::Graph, "goal", goal_reference)?, - graph_document_id, - )?; - self.collect_bundled_template_includes( - files, - bundled, - &workflow_template_root, - graph_document_id, - )?; - } else { - self.collect_template_include_files( - files, - TemplateSource::new( - graph_manifest_path.clone(), - workflow_template_root.clone(), - goal_reference.to_owned(), - ), - graph_document_id, - graph_document_id, - )?; - } - } - - let mut nodes = graph.nodes.values().collect::>(); - nodes.sort_by(|left, right| left.id.cmp(&right.id)); - for node in nodes { - if let Some(prompt_reference) = node.attrs.get("prompt").and_then(AttrValue::as_str) { - if !prompt_reference.starts_with('@') { - self.collect_template_include_files( - files, - TemplateSource::new( - graph_manifest_path.clone(), - workflow_template_root.clone(), - prompt_reference.to_owned(), - ), - graph_document_id, - graph_document_id, - )?; - } - } - - let mut attributes = node.attrs.iter().collect::>(); - attributes.sort_by_key(|(name, _)| *name); - for (name, value) in attributes { - let Some(value) = value.as_str() else { - continue; - }; - let Some(ReferenceKind::FileInline) = - reference_kind_for_attribute(AttributeScope::Node, name, value) - else { - continue; - }; - let reference = value.strip_prefix('@').ok_or_else(|| { - anyhow!("file inline reference must start with '@': {name}={value}") - })?; - let bundled = self.collect_bundled_file( - files, - workflow_base_dir, - reference, - CollectedFileReferenceType::FileInline, - ReferenceKind::FileInline, - graph_document_id, - )?; - - if name == "prompt" { - self.collect_bundled_template_includes( - files, - bundled, - &workflow_template_root, - graph_document_id, - )?; - } - } - - if let Some(import_reference) = node.attrs.get("import").and_then(AttrValue::as_str) { - let imported = self.collect_bundled_file( - files, - workflow_base_dir, - import_reference, - CollectedFileReferenceType::Import, - manifest_attr_reference_kind(AttributeScope::Node, "import", import_reference)?, - graph_document_id, - )?; - let import_key = - manifest_path_from_absolute(&self.document(imported).access_path, &self.cwd)? - .to_string(); - if visited_imports.insert(import_key) { - self.collect_workflow_files(imported, files, visited_imports)?; - } - } - - if let Some(child_reference) = node - .attrs - .get("stack.child_workflow") - .and_then(AttrValue::as_str) - { - manifest_attr_reference_kind( - AttributeScope::Node, - "stack.child_workflow", - child_reference, - )? - .validate(child_reference) - .map_err(anyhow::Error::new)?; - let child_provisional_path = virtual_reference_path( - self.document(graph_document_id) - .provisional_path - .parent() - .unwrap_or_else(|| Path::new(".")), - child_reference, - )?; - self.collect_workflow_entry( - Path::new(child_reference), - workflow_base_dir, - child_provisional_path, - )?; - } - } - - Ok(()) - } - - /// Collects the template dependency closure of an already-bundled - /// `@`-referenced file (a goal or prompt document). - fn collect_bundled_template_includes( - &mut self, - files: &mut BTreeMap, - bundled: DocumentId, - workflow_template_root: &ManifestPath, - from_document: DocumentId, - ) -> Result<()> { - let document = self.document(bundled); - let source = document.source.clone(); - let bundled_manifest_path = manifest_path_from_absolute(&document.access_path, &self.cwd)?; - let template_root = - template_root_for_bundled_file(&bundled_manifest_path, workflow_template_root)?; - self.collect_template_include_files( - files, - TemplateSource::new(bundled_manifest_path, template_root, source), - bundled, - from_document, - ) - } - - fn collect_template_include_files( - &mut self, - files: &mut BTreeMap, - source: TemplateSource, - source_document: DocumentId, - from_document: DocumentId, - ) -> Result<()> { - let source_path = source.path.clone(); - let stable_root = stable_template_root(self.document(source_document), &source)?; - let store = FilesystemTemplateStore::new(self.cwd.clone()); - let closure = discover_static_dependency_closure([source], &store) - .context("failed to discover template dependencies")?; - self.verify_recorded_template_dependencies(&source_path, &closure, files, from_document)?; - - let mut sources = closure.sources.into_iter().collect::>(); - sources.sort_by_key(|(path, _)| path.to_string()); - for (path, source) in sources { - if path == source_path { - continue; - } - let relative = path - .as_path() - .strip_prefix(source.root.as_path()) - .map_err(|_| { - anyhow!( - "template path {path} is outside its logical root {}", - source.root - ) - })?; - let provisional_path = normalize_relative_path(&stable_root.join(relative))?; - let key = path.to_string(); - if let Some(existing) = files.get(&key) { - let existing_path = &self.document(existing.document).provisional_path; - if existing_path != &provisional_path { - bail!( - "collected file has conflicting logical coordinates `{}` and `{}`", - existing_path.display(), - provisional_path.display() - ); - } - continue; - } - - let access_path = lexically_normalize_access_path(&self.cwd.join(path.as_path())); - let document = self.insert_document( - &access_path, - provisional_path, - ComponentRole::Workflow, - source.content, - ); - files.insert(key.clone(), FileRecord { - document, - reference: FileReferenceRecord { - type_: CollectedFileReferenceType::FileInline, - original: key, - from_document: Some(from_document), - }, - }); - } - Ok(()) - } - - fn verify_recorded_template_dependencies( - &self, - source_path: &ManifestPath, - closure: &TemplateDependencyClosure, - files: &BTreeMap, - from_document: DocumentId, - ) -> Result<()> { - let Some(source) = closure.sources.get(source_path) else { - return Ok(()); - }; - let mut bundled_files = closure - .sources - .iter() - .map(|(path, source)| (path.clone(), source.content.clone())) - .collect::>(); - for (key, file) in files { - let path = ManifestPath::from_wire(key) - .ok_or_else(|| anyhow!("invalid collected file key: {key}"))?; - bundled_files.insert(path, self.document(file.document).source.clone()); - } - let allowed = bundled_files.keys().cloned().collect(); - let store = - RecordingTemplateStore::with_allowed(BundleTemplateStore::new(bundled_files), allowed); - let context = TemplateContext::for_input_scan(self.inputs.clone()); - render_source( - source, - &context, - Arc::new(store), - TemplateRenderMode::Lenient, - ) - .with_context(|| { - let from = - manifest_path_from_absolute(&self.document(from_document).access_path, &self.cwd) - .map_or_else(|_| source_path.to_string(), |path| path.to_string()); - format!("failed to verify template dependencies for {from}") - })?; - Ok(()) - } - - fn collect_config_dockerfile( - &mut self, - config: DocumentId, - files: &mut BTreeMap, - ) -> Result<()> { - let layer = self - .document(config) - .source - .parse::() - .context("Failed to parse run config TOML")?; - let config_access_path = self.document(config).access_path.clone(); - let base_dir = config_access_path - .parent() - .unwrap_or_else(|| Path::new(".")); - - let mut environments = layer.environments.iter().collect::>(); - environments.sort_by_key(|(name, _)| *name); - for (_, environment) in environments { - self.collect_environment_dockerfile( - files, - base_dir, - config, - environment.image.as_ref(), - )?; - } - if let Some(run_environment) = layer.run.as_ref().and_then(|run| run.environment.as_ref()) { - self.collect_environment_dockerfile( - files, - base_dir, - config, - run_environment.image.as_ref(), - )?; - } - Ok(()) - } - - fn collect_environment_dockerfile( - &mut self, - files: &mut BTreeMap, - base_dir: &Path, - config: DocumentId, - image: Option<&EnvironmentImageLayer>, - ) -> Result<()> { - let dockerfile = image.and_then(|image| image.dockerfile.as_ref()); - let Some(EnvironmentDockerfileLayer::Path { path }) = dockerfile else { - return Ok(()); - }; - self.collect_bundled_file( - files, - base_dir, - path, - CollectedFileReferenceType::Dockerfile, - ReferenceKind::Dockerfile, - config, - )?; - Ok(()) - } - - fn collect_bundled_file( - &mut self, - files: &mut BTreeMap, - base_dir: &Path, - reference: &str, - reference_type: CollectedFileReferenceType, - reference_kind: ReferenceKind, - from_document: DocumentId, - ) -> Result { - reference_kind - .validate(reference) - .map_err(anyhow::Error::new)?; - - let access_path = normalize_absolute_path(base_dir, reference) - .ok_or_else(|| anyhow!("unsupported manifest reference: {reference}"))?; - let manifest_path = manifest_path_from_absolute(&access_path, &self.cwd)?; - let key = manifest_path.to_string(); - let provisional_path = virtual_reference_path( - self.document(from_document) - .provisional_path - .parent() - .unwrap_or_else(|| Path::new(".")), - reference, - )?; - - if let Some(existing) = files.get(&key) { - let existing_path = &self.document(existing.document).provisional_path; - if existing_path != &provisional_path { - bail!( - "collected file has conflicting logical coordinates `{}` and `{}`", - existing_path.display(), - provisional_path.display() - ); - } - return Ok(existing.document); - } - - let source = std::fs::read_to_string(&access_path) - .with_context(|| format!("Failed to read {}", access_path.display()))?; - let document = self.insert_document( - &access_path, - provisional_path, - self.document(from_document).component, - source, - ); - files.insert(key, FileRecord { - document, - reference: FileReferenceRecord { - type_: reference_type, - original: reference.to_owned(), - from_document: Some(from_document), - }, - }); - Ok(document) - } - - fn seed_config_document( - &mut self, - config: CollectedSourceInput, - role: ComponentRole, - ) -> Result { - let access_path = normalized_absolute_access_path(&config.access_path)?; - let provisional_path = seed_component_path(&access_path, &self.cwd, role)?; - Ok(self.insert_document(&access_path, provisional_path, role, config.source)) - } - - fn finish( - self, - entrypoint: DocumentId, - project_config: Option, - user_config: Option, - ) -> Result { - let documents = finalize_documents(self.documents)?; - let mut workflows = BTreeMap::new(); - for workflow in self.workflows.into_values() { - let graph = documents[workflow.graph.0].clone(); - let config = workflow - .config - .map(|document| documents[document.0].clone()); - let mut files = BTreeMap::new(); - for file in workflow.files.into_values() { - let document = documents[file.document.0].clone(); - let from_access_path = file - .reference - .from_document - .map(|from| documents[from.0].access_path.clone()); - files.insert(document.path.clone(), CollectedFile { - document, - reference: CollectedFileReference { - type_: file.reference.type_, - original: file.reference.original, - from_access_path, - }, - }); - } - workflows.insert(graph.path.clone(), CollectedWorkflow { - graph, - config, - files, - }); - } - - Ok(CollectedWorkflowBundle { - entrypoint: documents[entrypoint.0].path.clone(), - workflows, - project_config: project_config.map(|document| documents[document.0].clone()), - user_config: user_config.map(|document| documents[document.0].clone()), - }) - } -} - -/// Finalizes document records into collected documents, rejecting conflicting -/// physical aliases and virtual-coordinate collisions. -fn finalize_documents(records: Vec) -> Result> { - let mut deficits = BTreeMap::::new(); - for record in &records { - let deficit = leading_parent_count(&record.provisional_path); - deficits - .entry(record.component) - .and_modify(|current| *current = (*current).max(deficit)) - .or_insert(deficit); - } - - let paths = records - .iter() - .map(|record| { - finalize_component_path( - &record.provisional_path, - record.component, - deficits.get(&record.component).copied().unwrap_or_default(), - ) - }) - .collect::>>()?; - - let mut order = (0..records.len()).collect::>(); - order.sort_by(|left, right| { - paths[*left] - .cmp(&paths[*right]) - .then_with(|| records[*left].access_path.cmp(&records[*right].access_path)) - }); - - let mut physical_to_virtual = BTreeMap::::new(); - let mut virtual_to_physical = BTreeMap::::new(); - for index in order { - let physical = std::fs::canonicalize(&records[index].access_path).with_context(|| { - format!( - "failed to identify collected file {}", - records[index].access_path.display() - ) - })?; - let path = &paths[index]; - if let Some(existing) = physical_to_virtual.get(&physical) { - if existing != path { - bail!( - "one physical file has conflicting collected coordinates `{existing}` and `{path}`" - ); - } - } - if let Some(existing) = virtual_to_physical.get(path) { - if existing != &physical { - bail!("collected coordinate `{path}` maps to multiple physical files"); - } - } - physical_to_virtual.insert(physical.clone(), path.clone()); - virtual_to_physical.insert(path.clone(), physical); - } - - Ok(records - .into_iter() - .zip(paths) - .map(|(record, path)| CollectedDocument { - access_path: record.access_path, - path, - source: record.source, - }) - .collect()) -} - -pub(super) fn collect_workflow_bundle( - input: CollectWorkflowBundleInput<'_>, -) -> Result { - let mut bundler = WorkflowBundler::try_new(input.cwd, input.inputs)?; - let root_graph_access_path = normalized_absolute_access_path(&input.root_location.graph)?; - let root_graph_path = seed_component_path( - &root_graph_access_path, - &bundler.cwd, - ComponentRole::Workflow, - )?; - - let project_config = input - .project_config - .map(|config| bundler.seed_config_document(config, ComponentRole::ProjectConfig)) - .transpose()?; - let user_config = input - .user_config - .map(|config| bundler.seed_config_document(config, ComponentRole::UserConfig)) - .transpose()?; - - let entrypoint = bundler.collect_workflow_location(&input.root_location, root_graph_path)?; - if let Some(project_config) = project_config { - let root_key = - manifest_path_from_absolute(&bundler.document(entrypoint).access_path, &bundler.cwd)? - .to_string(); - let mut root = bundler - .workflows - .remove(&root_key) - .ok_or_else(|| anyhow!("root workflow missing from collected workflow bundle"))?; - bundler.collect_config_dockerfile(project_config, &mut root.files)?; - bundler.workflows.insert(root_key, root); - } - - bundler.finish(entrypoint, project_config, user_config) -} - -fn seed_component_path( - access_path: &Path, - cwd: &Path, - component: ComponentRole, -) -> Result { - if !matches!(component, ComponentRole::UserConfig) { - if let Ok(relative) = access_path.strip_prefix(cwd) { - let relative = normalize_relative_path(relative)?; - if leading_parent_count(&relative) == 0 && !relative.as_os_str().is_empty() { - return Ok(relative); - } - } - } - - let file_name = access_path - .file_name() - .ok_or_else(|| anyhow!("collected root has no file name: {}", access_path.display()))?; - let root = match component { - ComponentRole::Workflow => PathBuf::from("_fabro_external/entrypoint"), - ComponentRole::ProjectConfig => PathBuf::from("_fabro_external/project_config"), - ComponentRole::UserConfig => PathBuf::from("_fabro_external/user_config"), - }; - normalize_relative_path(&root.join(file_name)) -} - -fn stable_template_root(document: &DocumentRecord, source: &TemplateSource) -> Result { - let relative = source - .path - .as_path() - .strip_prefix(source.root.as_path()) - .map_err(|_| { - anyhow!( - "template source {} is outside its logical root {}", - source.path, - source.root - ) - })?; - let mut root = document.provisional_path.clone(); - for component in relative.components() { - if matches!(component, Component::Normal(_)) && !root.pop() { - bail!( - "template source {} cannot be placed under its collected root", - source.path - ); - } - } - Ok(root) -} - -fn finalize_component_path( - provisional: &Path, - component: ComponentRole, - deficit: usize, -) -> Result { - let path = if deficit == 0 { - normalize_relative_path(provisional)? - } else { - let mut prefix = PathBuf::from("_fabro_rebased"); - prefix.push(component.label()); - for _ in 0..deficit { - prefix.push("anchor"); - } - normalize_relative_path(&prefix.join(provisional))? - }; - CollectedPath::try_new(virtual_path_to_wire(&path)?) -} - -fn virtual_path_to_wire(path: &Path) -> Result { - let mut segments = Vec::new(); - for component in path.components() { - let Component::Normal(segment) = component else { - bail!("collected path is not finalized: {}", path.display()); - }; - segments.push( - segment - .to_str() - .ok_or_else(|| anyhow!("collected path is not valid UTF-8"))?, - ); - } - Ok(segments.join("/")) -} - -fn virtual_sibling_path(path: &Path, sibling: &Path) -> Result { - let parent = path.parent().unwrap_or_else(|| Path::new(".")); - normalize_relative_path(&parent.join(sibling)) -} - -fn virtual_reference_path(base: &Path, reference: &str) -> Result { - let reference_path = Path::new(reference); - if reference_path.is_absolute() || reference.starts_with('~') { - bail!("unsupported collected reference: {reference}"); - } - normalize_relative_path(&base.join(reference_path)) -} - -fn normalize_relative_path(path: &Path) -> Result { - let mut normalized = PathBuf::new(); - for component in path.components() { - match component { - Component::CurDir => {} - Component::Normal(part) => normalized.push(part), - Component::ParentDir => { - if normalized.file_name().is_some() { - normalized.pop(); - } else { - normalized.push(".."); - } - } - Component::RootDir | Component::Prefix(_) => { - bail!("collected path must be relative: {}", path.display()); - } - } - } - Ok(normalized) -} - -fn leading_parent_count(path: &Path) -> usize { - path.components() - .take_while(|component| matches!(component, Component::ParentDir)) - .count() -} - -fn lexically_normalize_access_path(path: &Path) -> PathBuf { - let mut normalized = PathBuf::new(); - for component in path.components() { - match component { - Component::CurDir => {} - Component::Normal(part) => normalized.push(part), - Component::ParentDir => { - normalized.pop(); - } - Component::RootDir => normalized.push(Path::new("/")), - Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), - } - } - normalized -} - -fn normalized_absolute_access_path(path: &Path) -> Result { - let absolute = std::path::absolute(path) - .with_context(|| format!("failed to make collected path absolute: {}", path.display()))?; - Ok(lexically_normalize_access_path(&absolute)) -} - -pub(super) fn normalize_absolute_path(base_dir: &Path, reference: &str) -> Option { - let path = Path::new(reference); - if path.is_absolute() || reference.starts_with('~') { - return None; - } - Some(lexically_normalize_access_path(&base_dir.join(path))) -} - -pub(super) fn manifest_path_from_absolute(path: &Path, cwd: &Path) -> Result { - ManifestPath::from_absolute(path, cwd) - .ok_or_else(|| anyhow!("Failed to compute manifest path for {}", path.display())) -} - -fn manifest_parent_or_dot(path: &ManifestPath) -> Result { - let parent = path.parent_or_dot().to_string_lossy(); - ManifestPath::from_wire(&parent) - .ok_or_else(|| anyhow!("invalid manifest parent path for {path}: {parent}")) -} - -fn template_root_for_bundled_file( - path: &ManifestPath, - workflow_template_root: &ManifestPath, -) -> Result { - if manifest_path_is_within_root(path, workflow_template_root) { - Ok(workflow_template_root.clone()) - } else { - manifest_parent_or_dot(path) - } -} - -fn manifest_path_is_within_root(path: &ManifestPath, root: &ManifestPath) -> bool { - if root.as_path().as_os_str().is_empty() { - return !matches!( - path.as_path().components().next(), - Some(Component::ParentDir) - ); - } - path.starts_with(root) -} - -fn manifest_attr_reference_kind( - scope: AttributeScope, - key: &str, - value: &str, -) -> Result { - reference_kind_for_attribute(scope, key, value) - .ok_or_else(|| anyhow!("unsupported manifest reference attribute: {key}={value}")) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn write_file(path: &Path, source: &str) { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).expect("fixture directory should be created"); - } - std::fs::write(path, source).expect("fixture file should be written"); - } - - fn collect_graph(cwd: &Path, graph: &Path) -> Result { - let inputs = HashMap::new(); - let root_location = WorkflowLocation::resolve(graph, cwd)?; - collect_workflow_bundle(CollectWorkflowBundleInput { - cwd, - root_location, - inputs: &inputs, - project_config: None, - user_config: None, - }) - } - - fn logical_contents(bundle: &CollectedWorkflowBundle) -> BTreeMap { - let mut contents = BTreeMap::new(); - if let Some(config) = &bundle.project_config { - contents.insert(config.path.to_string(), config.source.clone()); - } - if let Some(config) = &bundle.user_config { - contents.insert(config.path.to_string(), config.source.clone()); - } - for workflow in bundle.workflows.values() { - contents.insert( - workflow.graph.path.to_string(), - workflow.graph.source.clone(), - ); - if let Some(config) = &workflow.config { - contents.insert(config.path.to_string(), config.source.clone()); - } - for file in workflow.files.values() { - contents.insert(file.document.path.to_string(), file.document.source.clone()); - } - } - contents - } - - fn logical_provenance( - bundle: &CollectedWorkflowBundle, - ) -> BTreeMap)> { - let mut paths_by_access = HashMap::new(); - if let Some(config) = &bundle.project_config { - paths_by_access.insert(config.access_path.clone(), config.path.to_string()); - } - if let Some(config) = &bundle.user_config { - paths_by_access.insert(config.access_path.clone(), config.path.to_string()); - } - for workflow in bundle.workflows.values() { - paths_by_access.insert( - workflow.graph.access_path.clone(), - workflow.graph.path.to_string(), - ); - if let Some(config) = &workflow.config { - paths_by_access.insert(config.access_path.clone(), config.path.to_string()); - } - for file in workflow.files.values() { - paths_by_access.insert( - file.document.access_path.clone(), - file.document.path.to_string(), - ); - } - } - - let mut provenance = BTreeMap::new(); - for workflow in bundle.workflows.values() { - for file in workflow.files.values() { - let from = file.reference.from_access_path.as_ref().map(|access_path| { - paths_by_access - .get(access_path) - .expect("reference source should be collected") - .clone() - }); - provenance.insert( - file.document.path.to_string(), - (file.reference.type_, file.reference.original.clone(), from), - ); - } - } - provenance - } - - #[test] - fn collected_path_rejects_parent_components() { - assert!(CollectedPath::try_new("../prompt.md").is_err()); - } - - #[test] - fn collected_path_rejects_non_canonical_forms() { - for value in ["", ".", "a/./b", "a//b", "a/../b", "C:/a", "a\\b", "a\nb"] { - assert!(CollectedPath::try_new(value).is_err(), "accepted {value:?}"); - } - } - - #[test] - fn component_rebase_is_uniform_and_preserves_relative_relationships() { - let root = Path::new("_fabro_external/entrypoint/workflow.fabro"); - let sibling = virtual_reference_path( - root.parent().expect("entrypoint should have a parent"), - "../../../sibling/workflow.fabro", - ) - .expect("reference should normalize"); - let deficit = leading_parent_count(&sibling); - - let rebased_root = finalize_component_path(root, ComponentRole::Workflow, deficit) - .expect("root should finalize"); - let rebased_sibling = finalize_component_path(&sibling, ComponentRole::Workflow, deficit) - .expect("sibling should finalize"); - let resolved = virtual_reference_path( - rebased_root - .as_path() - .parent() - .expect("rebased root should have a parent"), - "../../../sibling/workflow.fabro", - ) - .expect("rebased reference should normalize"); - - assert_eq!(resolved, rebased_sibling.as_path()); - assert!(!rebased_root.as_str().contains("..")); - assert!(!rebased_sibling.as_str().contains("..")); - } - - #[test] - fn collector_captures_complete_workflow_bundle() { - let temp = tempfile::tempdir().expect("temp directory should be created"); - let project = temp.path().join("project"); - let root = project.join(".fabro/workflows/root"); - let child = project.join(".fabro/workflows/child"); - let project_config_path = project.join(".fabro/project.toml"); - let user_config_path = temp.path().join("home/.fabro/config.toml"); - let project_config = r#"_version = 1 - -[environments.project] -provider = "docker" - -[environments.project.image] -dockerfile = { path = "Project.Dockerfile" } -"#; - let workflow_config = r#"_version = 1 - -[workflow] -graph = "workflow.fabro" - -[environments.workflow] -provider = "docker" - -[environments.workflow.image] -dockerfile = { path = "Dockerfile" } -"#; - let user_config = "_version = 1\n"; - write_file(&project_config_path, project_config); - write_file(&project.join(".fabro/Project.Dockerfile"), "FROM project\n"); - write_file(&user_config_path, user_config); - write_file(&root.join("workflow.toml"), workflow_config); - write_file(&root.join("Dockerfile"), "FROM workflow\n"); - write_file( - &root.join("workflow.fabro"), - r#"digraph Root { - start [shape=Mdiamond] - prompt [prompt="@prompts/plan.md"] - imported [import="imports/shared.fabro"] - child [shape=house, stack.child_workflow="../child/workflow.fabro"] - exit [shape=Msquare] - start -> prompt -> imported -> child -> exit - }"#, - ); - write_file( - &root.join("prompts/plan.md"), - r#"{% include "partial.md" %}"#, - ); - write_file(&root.join("prompts/partial.md"), "partial\n"); - write_file( - &root.join("imports/shared.fabro"), - r#"digraph Shared { - start [shape=Mdiamond] - shared [prompt="@../prompts/shared.md"] - exit [shape=Msquare] - start -> shared -> exit - }"#, - ); - write_file(&root.join("prompts/shared.md"), "shared\n"); - write_file(&child.join("workflow.toml"), workflow_config); - write_file(&child.join("Dockerfile"), "FROM child\n"); - write_file( - &child.join("workflow.fabro"), - "digraph Child { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }", - ); - - let inputs = HashMap::new(); - let bundle = collect_workflow_bundle(CollectWorkflowBundleInput { - cwd: &project, - root_location: WorkflowLocation::resolve(&root.join("workflow.toml"), &project) - .expect("root workflow should resolve"), - inputs: &inputs, - project_config: Some(CollectedSourceInput { - access_path: project_config_path, - source: project_config.to_owned(), - }), - user_config: Some(CollectedSourceInput { - access_path: user_config_path, - source: user_config.to_owned(), - }), - }) - .expect("workflow bundle should collect"); - - assert_eq!( - bundle.entrypoint.as_str(), - ".fabro/workflows/root/workflow.fabro" - ); - assert_eq!(bundle.workflows.len(), 2); - assert_eq!( - logical_contents(&bundle) - .keys() - .map(String::as_str) - .collect::>(), - vec![ - ".fabro/Project.Dockerfile", - ".fabro/project.toml", - ".fabro/workflows/child/Dockerfile", - ".fabro/workflows/child/workflow.fabro", - ".fabro/workflows/child/workflow.toml", - ".fabro/workflows/root/Dockerfile", - ".fabro/workflows/root/imports/shared.fabro", - ".fabro/workflows/root/prompts/partial.md", - ".fabro/workflows/root/prompts/plan.md", - ".fabro/workflows/root/prompts/shared.md", - ".fabro/workflows/root/workflow.fabro", - ".fabro/workflows/root/workflow.toml", - "_fabro_external/user_config/config.toml", - ] - ); - } - - #[test] - fn external_collection_is_stable_when_the_checkout_moves() { - fn fixture(parent: &Path) -> (PathBuf, PathBuf) { - let cwd = parent.join("checkout"); - let workflow = parent.join("catalog/root/workflow.fabro"); - std::fs::create_dir_all(&cwd).expect("checkout should be created"); - write_file( - &workflow, - r#"digraph Root { - start [shape=Mdiamond] - work [prompt="@prompts/plan.md"] - exit [shape=Msquare] - start -> work -> exit - }"#, - ); - write_file(&parent.join("catalog/root/prompts/plan.md"), "plan\n"); - (cwd, workflow) - } - - let first = tempfile::tempdir().expect("first temp directory should be created"); - let second = tempfile::tempdir().expect("second temp directory should be created"); - let (first_cwd, first_workflow) = fixture(first.path()); - let (second_cwd, second_workflow) = fixture(second.path()); - - let first_bundle = collect_graph(&first_cwd, &first_workflow) - .expect("first workflow bundle should collect"); - let second_bundle = collect_graph(&second_cwd, &second_workflow) - .expect("second workflow bundle should collect"); - - assert_eq!(first_bundle.entrypoint, second_bundle.entrypoint); - assert_eq!( - logical_contents(&first_bundle), - logical_contents(&second_bundle) - ); - assert_eq!( - logical_provenance(&first_bundle), - logical_provenance(&second_bundle) - ); - assert_eq!( - first_bundle.entrypoint.as_str(), - "_fabro_external/entrypoint/workflow.fabro" - ); - } - - #[test] - fn external_sibling_workflow_reference_resolves_in_stable_namespace() { - fn fixture(parent: &Path) -> (PathBuf, PathBuf) { - let cwd = parent.join("checkout"); - let root_dir = parent.join("user/workflows/root"); - let child_dir = parent.join("user/workflows/child"); - let root = root_dir.join("workflow.fabro"); - std::fs::create_dir_all(&cwd).expect("checkout should be created"); - write_file( - &root, - r#"digraph Root { - start [shape=Mdiamond] - prompt [prompt="@prompts/root.md"] - imported [import="imports/root.fabro"] - child [shape=house, stack.child_workflow="../child/workflow.fabro"] - exit [shape=Msquare] - start -> prompt -> imported -> child -> exit - }"#, - ); - write_file( - &root_dir.join("prompts/root.md"), - r#"{% include "root-partial.md" %}"#, - ); - write_file(&root_dir.join("prompts/root-partial.md"), "root partial\n"); - write_file( - &root_dir.join("imports/root.fabro"), - r#"digraph Import { - start [shape=Mdiamond] - work [prompt="@../prompts/root-import.md"] - exit [shape=Msquare] - start -> work -> exit - }"#, - ); - write_file(&root_dir.join("prompts/root-import.md"), "root import\n"); - write_file( - &child_dir.join("workflow.fabro"), - r#"digraph Child { - start [shape=Mdiamond] - prompt [prompt="@prompts/child.md"] - imported [import="imports/child.fabro"] - exit [shape=Msquare] - start -> prompt -> imported -> exit - }"#, - ); - write_file( - &child_dir.join("prompts/child.md"), - r#"{% include "child-partial.md" %}"#, - ); - write_file( - &child_dir.join("prompts/child-partial.md"), - "child partial\n", - ); - write_file( - &child_dir.join("imports/child.fabro"), - r#"digraph Import { - start [shape=Mdiamond] - work [prompt="@../prompts/child-import.md"] - exit [shape=Msquare] - start -> work -> exit - }"#, - ); - write_file(&child_dir.join("prompts/child-import.md"), "child import\n"); - (cwd, root) - } - - let first = tempfile::tempdir().expect("first temp directory should be created"); - let second = tempfile::tempdir().expect("second temp directory should be created"); - let (first_cwd, first_root) = fixture(first.path()); - let (second_cwd, second_root) = fixture(second.path()); - let bundle = - collect_graph(&first_cwd, &first_root).expect("first workflow bundle should collect"); - let moved = - collect_graph(&second_cwd, &second_root).expect("moved workflow bundle should collect"); - let entrypoint = &bundle.entrypoint; - let resolved = virtual_reference_path( - entrypoint - .as_path() - .parent() - .expect("entrypoint should have a parent"), - "../child/workflow.fabro", - ) - .expect("child reference should resolve"); - - assert_eq!(resolved, Path::new("_fabro_external/child/workflow.fabro")); - assert!(bundle.workflows.contains_key( - &CollectedPath::try_new(resolved).expect("child path should be canonical") - )); - assert_eq!(logical_contents(&bundle), logical_contents(&moved)); - assert_eq!(logical_provenance(&bundle), logical_provenance(&moved)); - assert_eq!(bundle.entrypoint, moved.entrypoint); - for path in logical_contents(&bundle).into_keys() { - assert!(!path.contains(first.path().file_name().unwrap().to_string_lossy().as_ref())); - CollectedPath::try_new(path).expect("every collected coordinate should be canonical"); - } - } - - #[test] - fn repeated_references_collect_one_file() { - let temp = tempfile::tempdir().expect("temp directory should be created"); - let cwd = temp.path(); - let graph = cwd.join("workflow.fabro"); - write_file( - &graph, - r#"digraph Root { - start [shape=Mdiamond] - first [prompt="@prompt.md"] - second [prompt="@prompt.md"] - exit [shape=Msquare] - start -> first -> second -> exit - }"#, - ); - write_file(&cwd.join("prompt.md"), "prompt\n"); - - let bundle = collect_graph(cwd, &graph).expect("workflow bundle should collect"); - let root = bundle - .workflows - .get(&bundle.entrypoint) - .expect("root workflow should be present"); - - assert_eq!(root.files.len(), 1); - let assembled = - crate::assemble_current_manifest(bundle, cwd).expect("legacy manifest should assemble"); - assert_eq!(assembled.workflows["workflow.fabro"].files.len(), 1); - } - - #[cfg(unix)] - #[test] - fn collector_rejects_one_physical_file_with_two_coordinates() { - let temp = tempfile::tempdir().expect("temp directory should be created"); - let cwd = temp.path(); - let graph = cwd.join("workflow.fabro"); - write_file( - &graph, - r#"digraph Root { - start [shape=Mdiamond] - first [prompt="@first.md"] - second [prompt="@second.md"] - exit [shape=Msquare] - start -> first -> second -> exit - }"#, - ); - write_file(&cwd.join("actual.md"), "prompt\n"); - std::os::unix::fs::symlink("actual.md", cwd.join("first.md")) - .expect("first symlink should be created"); - std::os::unix::fs::symlink("actual.md", cwd.join("second.md")) - .expect("second symlink should be created"); - - let error = collect_graph(cwd, &graph).expect_err("alias should be rejected"); - - assert!( - error - .to_string() - .contains("one physical file has conflicting collected coordinates"), - "unexpected error: {error:#}" - ); - assert!(error.to_string().contains("first.md")); - assert!(error.to_string().contains("second.md")); - } - - #[test] - fn namespace_rejects_two_physical_files_at_one_coordinate() { - let temp = tempfile::tempdir().expect("temp directory should be created"); - let first = temp.path().join("first.md"); - let second = temp.path().join("second.md"); - write_file(&first, "first\n"); - write_file(&second, "second\n"); - let record = |access_path| DocumentRecord { - access_path, - provisional_path: PathBuf::from("shared.md"), - component: ComponentRole::Workflow, - source: String::new(), - }; - - let error = finalize_documents(vec![record(first), record(second)]) - .expect_err("virtual collision should be rejected"); - - assert!( - error - .to_string() - .contains("collected coordinate `shared.md` maps to multiple physical files"), - "unexpected error: {error:#}" - ); - } - - #[test] - fn namespace_identity_errors_keep_the_io_error_in_the_source_chain() { - let temp = tempfile::tempdir().expect("temp directory should be created"); - let record = DocumentRecord { - access_path: temp.path().join("missing.md"), - provisional_path: PathBuf::from("missing.md"), - component: ComponentRole::Workflow, - source: String::new(), - }; - - let error = - finalize_documents(vec![record]).expect_err("missing physical identity should fail"); - - assert!( - error - .chain() - .any(|cause| cause.downcast_ref::().is_some()), - "unexpected error chain: {error:#}" - ); - } - - #[test] - fn read_errors_keep_the_io_error_in_the_source_chain() { - let temp = tempfile::tempdir().expect("temp directory should be created"); - let cwd = temp.path(); - let graph = cwd.join("workflow.fabro"); - write_file( - &graph, - r#"digraph Root { - start [shape=Mdiamond] - work [prompt="@missing.md"] - exit [shape=Msquare] - start -> work -> exit - }"#, - ); - - let error = collect_graph(cwd, &graph).expect_err("missing file should fail"); - - assert!( - error - .chain() - .any(|cause| cause.downcast_ref::().is_some()), - "unexpected error chain: {error:#}" - ); - } - - #[test] - fn collector_does_not_push_an_ahead_branch() { - fn commit_all(repository: &git2::Repository, message: &str) -> git2::Oid { - let mut index = repository.index().expect("index should open"); - index - .add_all(["*"], git2::IndexAddOption::DEFAULT, None) - .expect("fixture files should be staged"); - index.write().expect("index should be written"); - let tree_id = index.write_tree().expect("tree should be written"); - let tree = repository.find_tree(tree_id).expect("tree should exist"); - let signature = git2::Signature::now("Fabro Test", "fabro@example.com") - .expect("signature should be valid"); - let parents = repository - .head() - .ok() - .and_then(|head| head.target()) - .map(|oid| { - repository - .find_commit(oid) - .expect("parent commit should exist") - }); - let parent_refs = parents.iter().collect::>(); - repository - .commit( - Some("refs/heads/main"), - &signature, - &signature, - message, - &tree, - &parent_refs, - ) - .expect("commit should be created") - } - - let temp = tempfile::tempdir().expect("temp directory should be created"); - let origin_path = temp.path().join("origin.git"); - let checkout = temp.path().join("checkout"); - let origin = - git2::Repository::init_bare(&origin_path).expect("bare origin should be initialized"); - let repository = git2::Repository::init(&checkout).expect("checkout should be initialized"); - repository - .set_head("refs/heads/main") - .expect("main should be selected"); - write_file( - &checkout.join("workflow.fabro"), - "digraph Root { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }", - ); - let first_commit = commit_all(&repository, "initial"); - let mut remote = repository - .remote( - "origin", - origin_path.to_str().expect("origin path should be UTF-8"), - ) - .expect("origin should be configured"); - remote - .push(&["refs/heads/main:refs/heads/main"], None) - .expect("initial commit should be pushed"); - drop(remote); - write_file(&checkout.join("README.md"), "ahead\n"); - let ahead_commit = commit_all(&repository, "ahead"); - assert_ne!(first_commit, ahead_commit); - - collect_graph(&checkout, &checkout.join("workflow.fabro")) - .expect("workflow bundle should collect"); - - let origin_commit = origin - .find_reference("refs/heads/main") - .expect("origin main should exist") - .target() - .expect("origin main should point to a commit"); - assert_eq!(origin_commit, first_commit); - } -} diff --git a/lib/components/fabro-manifest/src/workflow_bundler.rs b/lib/components/fabro-manifest/src/workflow_bundler.rs new file mode 100644 index 000000000..15d0a3d20 --- /dev/null +++ b/lib/components/fabro-manifest/src/workflow_bundler.rs @@ -0,0 +1,618 @@ +use std::collections::{HashMap, HashSet}; +use std::path::{Component, Path, PathBuf}; +use std::sync::Arc; + +use anyhow::{Context as _, Result, anyhow}; +use fabro_api::types; +use fabro_config::project::WorkflowLocation; +use fabro_config::{EnvironmentDockerfileLayer, EnvironmentImageLayer, SettingsLayer}; +use fabro_graphviz::graph::AttrValue; +use fabro_graphviz::parser; +use fabro_template::{ + BundleTemplateStore, FilesystemTemplateStore, RecordingTemplateStore, TemplateContext, + TemplateDependencyClosure, TemplateRenderMode, TemplateSource, +}; +use fabro_types::ManifestPath; +use fabro_workflow::static_reference::{self, AttributeScope, ReferenceKind}; + +use crate::{manifest_path_from_absolute, normalize_absolute_path}; + +pub(super) struct WorkflowBundler<'a> { + cwd: &'a Path, + inputs: &'a HashMap, + workflows: HashMap, + visited_workflows: HashSet, +} + +impl<'a> WorkflowBundler<'a> { + pub(super) fn new(cwd: &'a Path, inputs: &'a HashMap) -> Self { + Self { + cwd, + inputs, + workflows: HashMap::new(), + visited_workflows: HashSet::new(), + } + } + + pub(super) fn bundle( + mut self, + root_location: &WorkflowLocation, + project_config: Option<(&ManifestPath, &str)>, + ) -> Result> { + self.collect_workflow_location(root_location)?; + + if let Some((config_path, source)) = project_config { + let root_key = manifest_path_from_absolute(&root_location.graph, self.cwd)?.to_string(); + let mut root = self + .workflows + .remove(&root_key) + .ok_or_else(|| anyhow!("root workflow missing from manifest bundle"))?; + self.collect_config_dockerfile(config_path, source, &mut root.files)?; + self.workflows.insert(root_key, root); + } + + Ok(self.workflows) + } + + fn collect_workflow_location(&mut self, location: &WorkflowLocation) -> Result<()> { + let dot_path = manifest_path_from_absolute(&location.graph, self.cwd)?; + let dot_key = dot_path.to_string(); + if !self.visited_workflows.insert(dot_key.clone()) { + return Ok(()); + } + + let source = std::fs::read_to_string(&location.graph) + .with_context(|| format!("Failed to read {}", location.graph.display()))?; + let config = if let Some(workflow_toml_path) = location.toml.as_ref() { + Some(types::ManifestWorkflowConfig { + path: manifest_path_from_absolute(workflow_toml_path, self.cwd)?.to_string(), + source: std::fs::read_to_string(workflow_toml_path) + .with_context(|| format!("Failed to read {}", workflow_toml_path.display()))?, + }) + } else { + None + }; + + let scan = WorkflowScanInput { + absolute_dot_path: location.graph.clone(), + dot_path, + source: source.clone(), + }; + let mut files = HashMap::new(); + let mut visited_imports = HashSet::new(); + if let Some(config) = config.as_ref() { + let config_path = ManifestPath::from_wire(&config.path) + .ok_or_else(|| anyhow!("invalid manifest workflow config path: {}", config.path))?; + self.collect_config_dockerfile(&config_path, &config.source, &mut files)?; + } + self.collect_workflow_files(&scan, &mut files, &mut visited_imports)?; + + self.workflows.insert(dot_key, types::ManifestWorkflow { + config, + files, + source, + }); + + Ok(()) + } + + fn collect_workflow_entry(&mut self, workflow: &Path, resolve_from: &Path) -> Result<()> { + let normalized_workflow = if workflow.extension().is_some() && workflow.is_relative() { + normalize_absolute_path(resolve_from, &workflow.to_string_lossy()).ok_or_else(|| { + anyhow!( + "unsupported manifest workflow reference: {}", + workflow.display() + ) + })? + } else { + workflow.to_path_buf() + }; + let location = WorkflowLocation::resolve(&normalized_workflow, resolve_from)?; + self.collect_workflow_location(&location) + } + + fn collect_workflow_files( + &mut self, + workflow: &WorkflowScanInput, + files: &mut HashMap, + visited_imports: &mut HashSet, + ) -> Result<()> { + let graph = parser::parse(&workflow.source) + .with_context(|| format!("Failed to parse {}", workflow.absolute_dot_path.display()))?; + let workflow_base_dir = workflow + .absolute_dot_path + .parent() + .unwrap_or_else(|| Path::new(".")); + let workflow_template_root = manifest_parent_or_dot(&workflow.dot_path)?; + + if let Some(goal_ref) = graph.attrs.get("goal").and_then(AttrValue::as_str) { + if goal_ref.starts_with('@') { + let bundled = self.collect_bundled_file( + files, + workflow_base_dir, + goal_ref.trim_start_matches('@'), + types::ManifestFileRefType::FileInline, + manifest_attr_reference_kind(AttributeScope::Graph, "goal", goal_ref)?, + Some(workflow.dot_path.clone()), + )?; + self.collect_bundled_template_includes(files, &bundled, &workflow_template_root)?; + } else { + self.collect_template_include_files( + files, + TemplateSource::new( + workflow.dot_path.clone(), + workflow_template_root.clone(), + goal_ref.to_owned(), + ), + Some(&workflow.dot_path), + )?; + } + } + + for node in graph.nodes.values() { + if let Some(prompt_ref) = node.attrs.get("prompt").and_then(AttrValue::as_str) { + if !prompt_ref.starts_with('@') { + self.collect_template_include_files( + files, + TemplateSource::new( + workflow.dot_path.clone(), + workflow_template_root.clone(), + prompt_ref.to_owned(), + ), + Some(&workflow.dot_path), + )?; + } + } + + for (name, value) in &node.attrs { + let Some(value) = value.as_str() else { + continue; + }; + let Some(ReferenceKind::FileInline) = + static_reference::reference_kind_for_attribute( + AttributeScope::Node, + name, + value, + ) + else { + continue; + }; + let reference = value.strip_prefix('@').ok_or_else(|| { + anyhow!("file inline reference must start with '@': {name}={value}") + })?; + let bundled = self.collect_bundled_file( + files, + workflow_base_dir, + reference, + types::ManifestFileRefType::FileInline, + ReferenceKind::FileInline, + Some(workflow.dot_path.clone()), + )?; + + if name == "prompt" { + self.collect_bundled_template_includes( + files, + &bundled, + &workflow_template_root, + )?; + } + } + + if let Some(import_ref) = node.attrs.get("import").and_then(AttrValue::as_str) { + let imported = self.collect_bundled_file( + files, + workflow_base_dir, + import_ref, + types::ManifestFileRefType::Import, + manifest_attr_reference_kind(AttributeScope::Node, "import", import_ref)?, + Some(workflow.dot_path.clone()), + )?; + let import_key = imported.path.to_string(); + if visited_imports.insert(import_key) { + let imported_source = std::fs::read_to_string(&imported.absolute_path) + .with_context(|| { + format!("Failed to read {}", imported.absolute_path.display()) + })?; + let imported_scan = WorkflowScanInput { + absolute_dot_path: imported.absolute_path, + dot_path: imported.path, + source: imported_source, + }; + self.collect_workflow_files(&imported_scan, files, visited_imports)?; + } + } + + if let Some(child_ref) = node + .attrs + .get("stack.child_workflow") + .and_then(AttrValue::as_str) + { + manifest_attr_reference_kind( + AttributeScope::Node, + "stack.child_workflow", + child_ref, + )? + .validate(child_ref) + .map_err(anyhow::Error::new)?; + self.collect_workflow_entry(Path::new(child_ref), workflow_base_dir)?; + } + } + + Ok(()) + } + + fn collect_bundled_template_includes( + &self, + files: &mut HashMap, + bundled: &BundledFile, + workflow_template_root: &ManifestPath, + ) -> Result<()> { + let source = std::fs::read_to_string(&bundled.absolute_path) + .with_context(|| format!("Failed to read {}", bundled.absolute_path.display()))?; + let template_root = template_root_for_bundled_file(&bundled.path, workflow_template_root)?; + self.collect_template_include_files( + files, + TemplateSource::new(bundled.path.clone(), template_root, source), + Some(&bundled.path), + ) + } + + fn collect_template_include_files( + &self, + files: &mut HashMap, + source: TemplateSource, + from: Option<&ManifestPath>, + ) -> Result<()> { + let source_path = source.path.clone(); + let store = FilesystemTemplateStore::new(self.cwd.to_path_buf()); + let closure = fabro_template::discover_static_dependency_closure([source], &store) + .context("failed to discover template dependencies")?; + self.verify_recorded_template_dependencies(&source_path, &closure, files, from)?; + + for (path, source) in closure.sources { + if path == source_path { + continue; + } + let key = path.to_string(); + files + .entry(key) + .or_insert_with(|| types::ManifestFileEntry { + content: source.content, + ref_: types::ManifestFileRef { + from: from.map(std::string::ToString::to_string), + original: path.to_string(), + type_: types::ManifestFileRefType::FileInline, + }, + }); + } + Ok(()) + } + + fn verify_recorded_template_dependencies( + &self, + source_path: &ManifestPath, + closure: &TemplateDependencyClosure, + files: &HashMap, + from: Option<&ManifestPath>, + ) -> Result<()> { + let Some(source) = closure.sources.get(source_path) else { + return Ok(()); + }; + let mut bundled_files = closure + .sources + .iter() + .map(|(path, source)| (path.clone(), source.content.clone())) + .collect::>(); + for (path, entry) in files { + if let Some(path) = ManifestPath::from_wire(path) { + bundled_files.insert(path, entry.content.clone()); + } + } + let allowed = bundled_files.keys().cloned().collect(); + let store = + RecordingTemplateStore::with_allowed(BundleTemplateStore::new(bundled_files), allowed); + let context = TemplateContext::for_input_scan(self.inputs.clone()); + fabro_template::render_source( + source, + &context, + Arc::new(store), + TemplateRenderMode::Lenient, + ) + .with_context(|| { + let from = + from.map_or_else(|| source_path.to_string(), std::string::ToString::to_string); + format!("failed to verify template dependencies for {from}") + })?; + Ok(()) + } + + fn collect_config_dockerfile( + &self, + config_path: &ManifestPath, + source: &str, + files: &mut HashMap, + ) -> Result<()> { + let layer = source + .parse::() + .context("Failed to parse run config TOML")?; + let absolute_config_path = self.cwd.join(config_path.as_path()); + let base_dir = absolute_config_path + .parent() + .unwrap_or_else(|| Path::new(".")); + + for environment in layer.environments.values() { + self.collect_environment_dockerfile( + files, + base_dir, + config_path, + environment.image.as_ref(), + )?; + } + if let Some(run_environment) = layer.run.as_ref().and_then(|run| run.environment.as_ref()) { + self.collect_environment_dockerfile( + files, + base_dir, + config_path, + run_environment.image.as_ref(), + )?; + } + Ok(()) + } + + fn collect_environment_dockerfile( + &self, + files: &mut HashMap, + base_dir: &Path, + config_path: &ManifestPath, + image: Option<&EnvironmentImageLayer>, + ) -> Result<()> { + let dockerfile = image.and_then(|image| image.dockerfile.as_ref()); + let Some(EnvironmentDockerfileLayer::Path { path }) = dockerfile else { + return Ok(()); + }; + self.collect_bundled_file( + files, + base_dir, + path, + types::ManifestFileRefType::Dockerfile, + ReferenceKind::Dockerfile, + Some(config_path.clone()), + )?; + Ok(()) + } + + fn collect_bundled_file( + &self, + files: &mut HashMap, + base_dir: &Path, + reference: &str, + ref_type: types::ManifestFileRefType, + reference_kind: ReferenceKind, + from: Option, + ) -> Result { + reference_kind + .validate(reference) + .map_err(anyhow::Error::new)?; + + let absolute_path = normalize_absolute_path(base_dir, reference) + .ok_or_else(|| anyhow!("unsupported manifest reference: {reference}"))?; + let path = manifest_path_from_absolute(&absolute_path, self.cwd)?; + let key = path.to_string(); + if !files.contains_key(&key) { + let content = std::fs::read_to_string(&absolute_path) + .with_context(|| format!("Failed to read {}", absolute_path.display()))?; + files.insert(key.clone(), types::ManifestFileEntry { + content, + ref_: types::ManifestFileRef { + from: from.map(|value| value.to_string()), + original: reference.to_owned(), + type_: ref_type, + }, + }); + } + + Ok(BundledFile { + absolute_path, + path, + }) + } +} + +#[derive(Clone)] +struct WorkflowScanInput { + absolute_dot_path: PathBuf, + dot_path: ManifestPath, + source: String, +} + +struct BundledFile { + absolute_path: PathBuf, + path: ManifestPath, +} + +fn manifest_parent_or_dot(path: &ManifestPath) -> Result { + let parent = path.parent_or_dot().to_string_lossy(); + ManifestPath::from_wire(&parent) + .ok_or_else(|| anyhow!("invalid manifest parent path for {path}: {parent}")) +} + +fn template_root_for_bundled_file( + path: &ManifestPath, + workflow_template_root: &ManifestPath, +) -> Result { + if manifest_path_is_within_root(path, workflow_template_root) { + Ok(workflow_template_root.clone()) + } else { + manifest_parent_or_dot(path) + } +} + +fn manifest_path_is_within_root(path: &ManifestPath, root: &ManifestPath) -> bool { + if root.as_path().as_os_str().is_empty() { + return !matches!( + path.as_path().components().next(), + Some(Component::ParentDir) + ); + } + path.starts_with(root) +} + +fn manifest_attr_reference_kind( + scope: AttributeScope, + key: &str, + value: &str, +) -> Result { + static_reference::reference_kind_for_attribute(scope, key, value) + .ok_or_else(|| anyhow!("unsupported manifest reference attribute: {key}={value}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_file(path: &Path, source: &str) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("fixture directory should be created"); + } + std::fs::write(path, source).expect("fixture file should be written"); + } + + fn bundle_graph(cwd: &Path, graph: &Path) -> Result> { + let inputs = HashMap::new(); + let root_location = WorkflowLocation::resolve(graph, cwd)?; + WorkflowBundler::new(cwd, &inputs).bundle(&root_location, None) + } + + #[test] + fn repeated_references_collect_one_file() { + let temp = tempfile::tempdir().expect("temp directory should be created"); + let graph = temp.path().join("workflow.fabro"); + write_file( + &graph, + r#"digraph Root { + start [shape=Mdiamond] + first [prompt="@prompt.md"] + second [prompt="@prompt.md"] + exit [shape=Msquare] + start -> first -> second -> exit + }"#, + ); + write_file(&temp.path().join("prompt.md"), "prompt\n"); + + let workflows = bundle_graph(temp.path(), &graph).expect("workflow should bundle"); + + assert_eq!(workflows["workflow.fabro"].files.len(), 1); + } + + #[test] + fn parse_errors_keep_the_graphviz_error_in_the_source_chain() { + let temp = tempfile::tempdir().expect("temp directory should be created"); + let graph = temp.path().join("workflow.fabro"); + write_file(&graph, "not a graph"); + + let error = bundle_graph(temp.path(), &graph).expect_err("invalid graph should fail"); + + assert!( + error + .chain() + .any(|cause| cause.downcast_ref::().is_some()), + "unexpected error chain: {error:#}" + ); + } + + #[test] + fn read_errors_keep_the_io_error_in_the_source_chain() { + let temp = tempfile::tempdir().expect("temp directory should be created"); + let graph = temp.path().join("workflow.fabro"); + write_file( + &graph, + r#"digraph Root { + start [shape=Mdiamond] + work [prompt="@missing.md"] + exit [shape=Msquare] + start -> work -> exit + }"#, + ); + + let error = bundle_graph(temp.path(), &graph).expect_err("missing file should fail"); + + assert!( + error + .chain() + .any(|cause| cause.downcast_ref::().is_some()), + "unexpected error chain: {error:#}" + ); + } + + #[test] + fn bundler_does_not_push_an_ahead_branch() { + fn commit_all(repository: &git2::Repository, message: &str) -> git2::Oid { + let mut index = repository.index().expect("index should open"); + index + .add_all(["*"], git2::IndexAddOption::DEFAULT, None) + .expect("fixture files should be staged"); + index.write().expect("index should be written"); + let tree_id = index.write_tree().expect("tree should be written"); + let tree = repository.find_tree(tree_id).expect("tree should exist"); + let signature = git2::Signature::now("Fabro Test", "fabro@example.com") + .expect("signature should be valid"); + let parents = repository + .head() + .ok() + .and_then(|head| head.target()) + .map(|oid| { + repository + .find_commit(oid) + .expect("parent commit should exist") + }); + let parent_refs = parents.iter().collect::>(); + repository + .commit( + Some("refs/heads/main"), + &signature, + &signature, + message, + &tree, + &parent_refs, + ) + .expect("commit should be created") + } + + let temp = tempfile::tempdir().expect("temp directory should be created"); + let origin_path = temp.path().join("origin.git"); + let checkout = temp.path().join("checkout"); + let origin = + git2::Repository::init_bare(&origin_path).expect("bare origin should be initialized"); + let repository = git2::Repository::init(&checkout).expect("checkout should be initialized"); + repository + .set_head("refs/heads/main") + .expect("main should be selected"); + write_file( + &checkout.join("workflow.fabro"), + "digraph Root { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }", + ); + let first_commit = commit_all(&repository, "initial"); + let mut remote = repository + .remote( + "origin", + origin_path.to_str().expect("origin path should be UTF-8"), + ) + .expect("origin should be configured"); + remote + .push(&["refs/heads/main:refs/heads/main"], None) + .expect("initial commit should be pushed"); + drop(remote); + write_file(&checkout.join("README.md"), "ahead\n"); + let ahead_commit = commit_all(&repository, "ahead"); + assert_ne!(first_commit, ahead_commit); + + bundle_graph(&checkout, &checkout.join("workflow.fabro")).expect("workflow should bundle"); + + let origin_commit = origin + .find_reference("refs/heads/main") + .expect("origin main should exist") + .target() + .expect("origin main should point to a commit"); + assert_eq!(origin_commit, first_commit); + } +}