diff --git a/src/compile/mod.rs b/src/compile/mod.rs index 928358ce..be7ada04 100644 --- a/src/compile/mod.rs +++ b/src/compile/mod.rs @@ -149,22 +149,10 @@ async fn compile_pipeline_inner( use crate::sanitize::SanitizeConfig; front_matter.sanitize_config_fields(); - info!("Parsed agent: '{}'", front_matter.name); - debug!("Description: {}", front_matter.description); - debug!("Target: {:?}", front_matter.target); - debug!( - "Engine: {} (model: {})", - front_matter.engine.engine_id(), - front_matter.engine.model().unwrap_or("default") - ); - debug!("Schedule: {:?}", front_matter.schedule()); - debug!("MCP servers configured: {}", front_matter.mcp_servers.len()); - // Resolve repos: new compact syntax or legacy repositories: + checkout: let (resolved_repos, resolved_checkout) = common::resolve_repos(&front_matter)?; front_matter.repositories = resolved_repos; front_matter.checkout = resolved_checkout; - debug!("Repositories: {}", front_matter.repositories.len()); // Validate checkout list against repositories common::validate_checkout_list(&front_matter.repositories, &front_matter.checkout)?; @@ -178,14 +166,11 @@ async fn compile_pipeline_inner( let yaml_output_path = resolve_output_path(input_path, output_path)?; // Select compiler based on target - let compiler: Box = match front_matter.target { - CompileTarget::OneES => Box::new(onees::OneESCompiler), - CompileTarget::Standalone => Box::new(standalone::StandaloneCompiler), - CompileTarget::Job => Box::new(job::JobCompiler), - CompileTarget::Stage => Box::new(stage::StageCompiler), - }; + let compiler = select_compiler(&front_matter.target); info!("Using {} compiler", compiler.target_name()); + log_pipeline_metadata(&front_matter); + // Snapshot the version in the existing output file before overwriting it. // Used below to emit an upgrade note when the compiler version changes. let existing_version = read_existing_pipeline_version(&yaml_output_path).await; @@ -212,19 +197,16 @@ async fn compile_pipeline_inner( // state (rewritten source + stale lock file, or rewritten lock file // pointing at unmigrated source) cannot escape: if rewrite fails, // we abort before the lock file ever gets touched. - let mut rewrote = false; - if codemod_report.changed() { - rewrote = perform_source_rewrite_if_needed( - input_path, - &content, - &leading_whitespace, - &front_matter_mapping, - &body_raw, - &source_sha256, - &codemod_report, - ) - .await?; - } + let rewrote = perform_source_rewrite_if_needed( + input_path, + &content, + &leading_whitespace, + &front_matter_mapping, + &body_raw, + &source_sha256, + &codemod_report, + ) + .await?; // Write output via atomic_write so a crash mid-write cannot leave a // half-written .lock.yml on disk. @@ -242,28 +224,14 @@ async fn compile_pipeline_inner( // Emit an upgrade note when an existing compiled file was produced by // a different compiler version. This makes version bumps visible in the // terminal and in CI logs without requiring the user to diff the output. - let current_version = env!("CARGO_PKG_VERSION"); - if let Some(ref old_version) = existing_version - && old_version != current_version - { - println!( - "note: upgraded {} (was v{}, now v{})", - yaml_output_path.display(), - old_version, - current_version, - ); - } + maybe_print_upgrade_note(&yaml_output_path, existing_version); // Update .gitattributes at the repo root so every compiled pipeline is // marked as a generated file with `merge=ours`. Best-effort: skip with a // debug-level log when the output is not inside a git repository, since // a non-git workspace is a valid use case (e.g. ad-hoc compilation). // Skipped during batch compilation (callers do one sync at the end). - if sync_gitattributes - && let Err(e) = sync_gitattributes_for_output(&yaml_output_path).await - { - debug!("Skipped .gitattributes update: {}", e); - } + maybe_sync_gitattributes_for_output(sync_gitattributes, &yaml_output_path).await; Ok(rewrote) } @@ -310,6 +278,9 @@ fn print_compile_success(compiler: &dyn Compiler, target: CompileTarget, output_ /// guard, and atomically rewrite the source `.md` if the content /// actually changed. Returns whether a write happened. /// +/// Returns `Ok(false)` immediately when no codemods fired (`!report.changed()`), +/// so callers do not need an outer guard. +/// /// On success, emits the documented stderr warning so users always /// see when codemods were applied. This warning is *not* gated by /// `--verbose`/`--debug`. @@ -322,6 +293,9 @@ async fn perform_source_rewrite_if_needed( source_sha256: &[u8; 32], report: &codemods::CodemodReport, ) -> Result { + if !report.changed() { + return Ok(false); + } let new_content = common::reconstruct_source(leading_whitespace, front_matter_mapping, body_raw)?; if new_content == original_content { @@ -371,6 +345,21 @@ async fn perform_source_rewrite_if_needed( Ok(true) } +/// Conditionally sync `.gitattributes` for `output_path`. +/// +/// When `sync` is `false` (batch mode), this is a no-op; batch callers +/// perform a single sync themselves after the whole batch completes. +/// When `sync` is `true`, any error is logged at debug level and swallowed — +/// the sync is best-effort and a non-git workspace is a valid use case. +async fn maybe_sync_gitattributes_for_output(sync: bool, output_path: &Path) { + if !sync { + return; + } + if let Err(e) = sync_gitattributes_for_output(output_path).await { + debug!("Skipped .gitattributes update: {}", e); + } +} + /// Locate the repo root containing `output_path`, scan it for all compiled /// pipelines, and write the managed block of `.gitattributes`. async fn sync_gitattributes_for_output(output_path: &Path) -> Result<()> { @@ -416,18 +405,11 @@ pub async fn compile_all_pipelines(skip_integrity: bool, debug_pipeline: bool) - println!("Found {} agentic pipeline(s):", detected.len()); for p in &detected { - let version_status = if p.version.is_empty() { - "(version unknown)".to_string() - } else if p.version == current_version { - "(up to date)".to_string() - } else { - format!("(out of date, compiled by v{})", p.version) - }; println!( " {} (source: {}) {}", p.yaml_path.display(), p.source, - version_status, + format_pipeline_version_status(&p.version, current_version), ); } @@ -446,16 +428,8 @@ pub async fn compile_all_pipelines(skip_integrity: bool, debug_pipeline: bool) - for pipeline in &detected { let yaml_output_path = root.join(&pipeline.yaml_path); - let source_candidate_from_yaml_dir = yaml_output_path - .parent() - .unwrap_or(root) - .join(&pipeline.source); - let source_candidate_from_root = root.join(&pipeline.source); - let source_path = if source_candidate_from_yaml_dir.exists() { - source_candidate_from_yaml_dir - } else { - source_candidate_from_root - }; + let source_path = + resolve_pipeline_source_path(&yaml_output_path, &pipeline.source, root); if !source_path.exists() { eprintln!( @@ -497,27 +471,10 @@ pub async fn compile_all_pipelines(skip_integrity: bool, debug_pipeline: bool) - // that would happen if each pipeline triggered its own // `sync_gitattributes_for_output` call. We reuse the already-detected // pipeline list rather than re-scanning the tree. - if let Some(repo_root) = - find_repo_root(&std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))) - { - let paths: Vec = detected.iter().map(|p| p.yaml_path.clone()).collect(); - if let Err(e) = gitattributes::update_gitattributes(&repo_root, paths).await { - debug!("Skipped .gitattributes update: {}", e); - } - } + batch_sync_gitattributes(&detected).await; println!(); - if rewrote_count > 0 { - println!( - "Done: {} compiled, {} skipped, {} failed; {} source file(s) rewritten by codemods.", - success_count, skip_count, fail_count, rewrote_count - ); - } else { - println!( - "Done: {} compiled, {} skipped, {} failed.", - success_count, skip_count, fail_count - ); - } + print_batch_compile_summary(success_count, skip_count, fail_count, rewrote_count); if fail_count > 0 { anyhow::bail!("{} pipeline(s) failed to compile", fail_count); @@ -627,12 +584,7 @@ pub async fn check_pipeline(pipeline_path: &str) -> Result<()> { common::validate_checkout_list(&front_matter.repositories, &front_matter.checkout)?; - let compiler: Box = match front_matter.target { - CompileTarget::OneES => Box::new(onees::OneESCompiler), - CompileTarget::Standalone => Box::new(standalone::StandaloneCompiler), - CompileTarget::Job => Box::new(job::JobCompiler), - CompileTarget::Stage => Box::new(stage::StageCompiler), - }; + let compiler = select_compiler(&front_matter.target); // Pass the header's relative source path to compile so the generated // header embeds the same path that was used during the original compilation. @@ -774,6 +726,118 @@ async fn read_existing_pipeline_version(path: &Path) -> Option { .map(|meta| meta.version) } +/// Map a [`CompileTarget`] to the corresponding boxed [`Compiler`] implementation. +fn select_compiler(target: &CompileTarget) -> Box { + match target { + CompileTarget::OneES => Box::new(onees::OneESCompiler), + CompileTarget::Standalone => Box::new(standalone::StandaloneCompiler), + CompileTarget::Job => Box::new(job::JobCompiler), + CompileTarget::Stage => Box::new(stage::StageCompiler), + } +} + +/// Print a version upgrade note when the existing compiled file was produced +/// by a different compiler version than the one currently running. +fn maybe_print_upgrade_note(yaml_output_path: &Path, existing_version: Option) { + let current_version = env!("CARGO_PKG_VERSION"); + if let Some(old_version) = existing_version + && old_version != current_version + { + println!( + "note: upgraded {} (was v{}, now v{})", + yaml_output_path.display(), + old_version, + current_version, + ); + } +} + +/// Format the version status label for a detected pipeline. +/// +/// Returns one of: +/// - `"(version unknown)"` — no version recorded in the header +/// - `"(up to date)"` — header version matches `current_version` +/// - `"(out of date, compiled by v)"` — version mismatch +fn format_pipeline_version_status(version: &str, current_version: &str) -> String { + if version.is_empty() { + "(version unknown)".to_string() + } else if version == current_version { + "(up to date)".to_string() + } else { + format!("(out of date, compiled by v{})", version) + } +} + +/// Resolve the source markdown path for a detected pipeline. +/// +/// Tries the path relative to the YAML file's directory first, then relative +/// to the scan root. This mirrors the lookup the ADO pipeline itself uses. +fn resolve_pipeline_source_path(yaml_output_path: &Path, source: &str, root: &Path) -> PathBuf { + let candidate_from_yaml_dir = yaml_output_path + .parent() + .unwrap_or(root) + .join(source); + if candidate_from_yaml_dir.exists() { + candidate_from_yaml_dir + } else { + root.join(source) + } +} + +/// Perform the post-batch `.gitattributes` sync. +/// +/// Looks up the git repo root from the current directory and rewrites the +/// managed `.gitattributes` block for all detected pipeline paths. Errors +/// are logged at debug level and swallowed — this step is best-effort. +async fn batch_sync_gitattributes(detected: &[crate::detect::DetectedPipeline]) { + if let Some(repo_root) = + find_repo_root(&std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))) + { + let paths: Vec = detected.iter().map(|p| p.yaml_path.clone()).collect(); + if let Err(e) = gitattributes::update_gitattributes(&repo_root, paths).await { + debug!("Skipped .gitattributes update: {}", e); + } + } +} + +/// Print the compile-batch summary line. +fn print_batch_compile_summary( + success_count: u32, + skip_count: u32, + fail_count: u32, + rewrote_count: u32, +) { + if rewrote_count > 0 { + println!( + "Done: {} compiled, {} skipped, {} failed; {} source file(s) rewritten by codemods.", + success_count, skip_count, fail_count, rewrote_count + ); + } else { + println!( + "Done: {} compiled, {} skipped, {} failed.", + success_count, skip_count, fail_count + ); + } +} + +/// Log front-matter metadata fields at the appropriate levels after parsing. +/// +/// Called once per compilation after repos are resolved so the repository +/// count reflects the final (post-resolution) value. +fn log_pipeline_metadata(front_matter: &FrontMatter) { + info!("Parsed agent: '{}'", front_matter.name); + debug!("Description: {}", front_matter.description); + debug!("Target: {:?}", front_matter.target); + debug!( + "Engine: {} (model: {})", + front_matter.engine.engine_id(), + front_matter.engine.model().unwrap_or("default") + ); + debug!("Schedule: {:?}", front_matter.schedule()); + debug!("MCP servers configured: {}", front_matter.mcp_servers.len()); + debug!("Repositories: {}", front_matter.repositories.len()); +} + /// Walk up from `start` to find the nearest directory containing `.git`. /// Walk up from `start` looking for the nearest ancestor containing a /// `.git` directory or file.