Skip to main content

spec_drift/
lib.rs

1//! `spec-drift` — semantic coherence analysis between a project's
2//! specification surfaces (README, AGENTS.md, examples, CI) and its Rust code.
3//!
4//! The library exposes the domain model, analyzers, and reporters so they can
5//! be embedded in editors or other tools. The `spec-drift` binary is a thin
6//! CLI wrapper over [`run_cli`].
7
8pub mod analyzers;
9pub mod auto_fix;
10pub mod baseline;
11pub mod blame;
12pub mod config;
13pub mod context;
14pub mod domain;
15pub mod error;
16pub mod llm;
17pub mod parsers;
18pub mod reporters;
19pub mod sources;
20pub mod suppress;
21pub mod workspace;
22
23pub use config::{Config, ConfigSource};
24pub use context::ProjectContext;
25pub use domain::{
26    Attribution, ClaimKind, CodeFact, Confidence, Divergence, FactKind, Location, RuleId, Severity,
27    SpecClaim,
28};
29pub use error::SpecDriftError;
30
31use analyzers::DriftAnalyzer;
32use rayon::prelude::*;
33use reporters::Reporter;
34use std::collections::HashSet;
35use std::path::Path;
36use std::path::PathBuf;
37use std::process::ExitCode;
38
39// ---------------------------------------------------------------------------
40// Pipeline
41// ---------------------------------------------------------------------------
42
43/// Execute every analyzer in parallel and return divergences sorted
44/// deterministically by `(file, line, rule)` so output can be diffed between
45/// runs.
46///
47/// Analyzers are independent by construction — see the `DriftAnalyzer` trait
48/// docstring — so `par_iter` is safe and parallelizes nicely on multi-pillar
49/// runs where one analyzer (e.g. `ExamplesAnalyzer`) spends most of its time
50/// blocked on `cargo`.
51pub fn run(ctx: &ProjectContext, analyzers: &[Box<dyn DriftAnalyzer>]) -> Vec<Divergence> {
52    let mut all: Vec<Divergence> = analyzers
53        .par_iter()
54        .flat_map_iter(|a| a.analyze(ctx))
55        .collect();
56
57    all.sort_by(|a, b| {
58        a.location
59            .file
60            .cmp(&b.location.file)
61            .then_with(|| a.location.line.cmp(&b.location.line))
62            .then_with(|| a.rule.as_str().cmp(b.rule.as_str()))
63    });
64    all
65}
66
67/// Apply a [`Config`] to a divergence set: drop suppressed items and apply
68/// severity overrides.
69pub fn apply_config(
70    mut divs: Vec<Divergence>,
71    cfg: &Config,
72    root: &std::path::Path,
73) -> Vec<Divergence> {
74    divs.retain(|d| !cfg.is_suppressed(d, root));
75    cfg.apply_severity_overrides(&mut divs);
76    divs
77}
78
79/// Promote every non-deterministic divergence one severity level. Mirrors the
80/// `--strict` CLI flag — deterministic rules stay untouched because they
81/// already carry unambiguous verdicts.
82pub fn apply_strict(mut divs: Vec<Divergence>) -> Vec<Divergence> {
83    for d in &mut divs {
84        if d.rule.confidence() != Confidence::Deterministic {
85            d.severity = d.severity.promoted();
86        }
87    }
88    divs
89}
90
91/// Render and baseline locations relative to the workspace root when possible.
92/// Analyzers work with absolute paths so suppression and blame can read files
93/// reliably; reporters should not leak machine-local temp or checkout paths.
94pub fn normalize_locations(mut divs: Vec<Divergence>, root: &Path) -> Vec<Divergence> {
95    for d in &mut divs {
96        if let Ok(rel) = d.location.file.strip_prefix(root) {
97            d.location.file = rel.to_path_buf();
98        }
99    }
100    divs
101}
102
103// ---------------------------------------------------------------------------
104// CLI orchestration
105// ---------------------------------------------------------------------------
106
107/// Pillar selection mirrors the CLI `--docs` / `--examples` / `--tests` / `--ci` flags.
108#[derive(Debug, Clone, Copy)]
109pub enum Pillar {
110    All,
111    Docs,
112    Examples,
113    Tests,
114    Ci,
115}
116
117/// Everything the orchestration layer needs to run a spec-drift analysis.
118#[derive(Debug, Clone)]
119pub struct RunConfig {
120    pub root: PathBuf,
121    pub pillar: Pillar,
122    pub format: String,
123    pub fix_prompt: bool,
124    pub config: Option<PathBuf>,
125    pub baseline: Option<PathBuf>,
126    pub diff: Option<String>,
127    pub package: Option<String>,
128    pub deny: Severity,
129    pub strict: bool,
130    pub no_llm: bool,
131    pub blame: bool,
132    pub fix: bool,
133}
134
135impl Default for RunConfig {
136    fn default() -> Self {
137        Self {
138            root: PathBuf::from("."),
139            pillar: Pillar::All,
140            format: "human".into(),
141            fix_prompt: false,
142            config: None,
143            baseline: None,
144            diff: None,
145            package: None,
146            deny: Severity::Notice,
147            strict: false,
148            no_llm: false,
149            blame: false,
150            fix: false,
151        }
152    }
153}
154
155/// Full end-to-end run: discover config, walk files, parse sources, select and
156/// run analyzers, apply suppression / baselines / strict, enrich with blame,
157/// and render through the chosen reporter.
158///
159/// Returns `ExitCode::SUCCESS` when no blocking divergences remain, and
160/// `ExitCode::from(1)` when the `deny` threshold is exceeded.
161pub fn run_cli(cfg: &RunConfig) -> anyhow::Result<ExitCode> {
162    let root = cfg.root.canonicalize()?;
163
164    let (config_path, source) = if let Some(ref p) = cfg.config {
165        (p.clone(), ConfigSource::Explicit)
166    } else {
167        let p = Config::discover(&root).unwrap_or_else(|| root.join("spec-drift.toml"));
168        (p, ConfigSource::Discovered)
169    };
170    let config = Config::load(&config_path, source)?;
171
172    let mut files = sources::FsWalker::walk(&root)?;
173    let changed_files = if let Some(ref reference) = cfg.diff {
174        match sources::GitHistory::changed_files(&root, reference) {
175            Some(changed) => Some(changed),
176            None => {
177                eprintln!(
178                    "spec-drift: --diff {reference}: git unavailable or ref unknown; \
179                     scanning full tree."
180                );
181                None
182            }
183        }
184    } else {
185        None
186    };
187
188    let mut analysis_root = root.clone();
189    if let Some(ref name) = cfg.package {
190        let packages = workspace::load(&root);
191        let pkg = workspace::find(&packages, name).map_err(anyhow::Error::msg)?;
192        analysis_root = pkg.root.clone();
193        files.rust = workspace::narrow_paths(files.rust, pkg);
194        files.markdown = workspace::narrow_paths(files.markdown, pkg);
195        files.yaml = workspace::narrow_paths(files.yaml, pkg);
196        files.makefiles = workspace::narrow_paths(files.makefiles, pkg);
197    }
198
199    let diff_scope = changed_files.map(|changed| {
200        let changed = if cfg.package.is_some() {
201            changed
202                .into_iter()
203                .filter(|p| p.starts_with(&analysis_root))
204                .collect()
205        } else {
206            changed
207        };
208        DiffScope::new(&root, changed)
209    });
210
211    let mut ctx = ProjectContext::new(&root);
212    ctx.analysis_root = analysis_root.clone();
213    ctx.rust_files = files.rust.clone();
214    ctx.markdown_files = files.markdown.clone();
215    ctx.yaml_files = files.yaml.clone();
216    ctx.makefile_files = files.makefiles.clone();
217
218    for rs in &ctx.rust_files {
219        match parsers::RustParser::parse(rs) {
220            Ok(facts) => ctx.code_facts.extend(facts),
221            Err(e) => eprintln!("spec-drift: skipping {}: {e}", rs.display()),
222        }
223    }
224
225    let analyzers = build_analyzers(cfg.pillar, &config, cfg.no_llm);
226    let mut divergences = run(&ctx, &analyzers);
227    if let Some(scope) = diff_scope.as_ref() {
228        divergences = filter_to_diff_scope(divergences, &root, scope);
229    }
230    divergences = apply_config(divergences, &config, &root);
231    divergences = suppress::apply_inline_ignores(divergences);
232    divergences = normalize_locations(divergences, &root);
233
234    if let Some(ref baseline_path) = cfg.baseline {
235        let baseline = baseline::load(baseline_path)?;
236        divergences = baseline::subtract(divergences, &baseline);
237    }
238
239    if cfg.strict {
240        divergences = apply_strict(divergences);
241    }
242
243    if cfg.blame {
244        divergences = blame::apply(divergences, &root, &blame::GitBlameEngine);
245    }
246
247    if cfg.fix {
248        let applied = auto_fix::apply_fixes(&divergences, &root);
249        eprintln!("spec-drift: applied {applied} auto-fix(es)");
250
251        let llm_client = llm::build_client(&config.llm, cfg.no_llm);
252        let nd_applied = apply_nondeterministic_fixes(
253            &root,
254            &analysis_root,
255            &files,
256            cfg.pillar,
257            &config,
258            cfg.no_llm,
259            &divergences,
260            &llm_client,
261            &ctx,
262        )?;
263        if nd_applied > 0 {
264            eprintln!("spec-drift: applied {nd_applied} coherency auto-correction(s)");
265        }
266    }
267
268    let rendered = if cfg.fix_prompt {
269        reporters::FixPromptReporter.render(&divergences)
270    } else {
271        match cfg.format.as_str() {
272            "json" => reporters::JsonReporter.render(&divergences),
273            "sarif" => reporters::SarifReporter.render(&divergences),
274            _ => reporters::HumanReporter.render(&divergences),
275        }
276    };
277    print!("{rendered}");
278
279    let has_blocking = divergences.iter().any(|d| d.severity >= cfg.deny);
280    if has_blocking {
281        Ok(ExitCode::from(1))
282    } else {
283        Ok(ExitCode::SUCCESS)
284    }
285}
286
287struct DiffScope {
288    changed: HashSet<PathBuf>,
289    implementation_changed: bool,
290    cargo_metadata_changed: bool,
291}
292
293impl DiffScope {
294    fn new(root: &Path, changed: HashSet<PathBuf>) -> Self {
295        let implementation_changed = changed.iter().any(|p| is_rust_implementation_path(root, p));
296        let cargo_metadata_changed = changed.iter().any(|p| is_cargo_manifest_path(p));
297
298        Self {
299            changed,
300            implementation_changed,
301            cargo_metadata_changed,
302        }
303    }
304}
305
306fn filter_to_diff_scope(divs: Vec<Divergence>, root: &Path, scope: &DiffScope) -> Vec<Divergence> {
307    divs.into_iter()
308        .filter(|d| {
309            divergence_is_in_changed_file(d, root, &scope.changed)
310                || could_be_diff_induced(d, scope)
311        })
312        .collect()
313}
314
315fn divergence_is_in_changed_file(d: &Divergence, root: &Path, changed: &HashSet<PathBuf>) -> bool {
316    if changed.contains(&d.location.file) {
317        return true;
318    }
319    if d.location.file.is_relative() {
320        return changed.contains(&root.join(&d.location.file));
321    }
322    if let Ok(rel) = d.location.file.strip_prefix(root) {
323        return changed.contains(rel);
324    }
325    false
326}
327
328fn could_be_diff_induced(d: &Divergence, scope: &DiffScope) -> bool {
329    match d.rule {
330        RuleId::SymbolAbsence
331        | RuleId::CompileFailure
332        | RuleId::DeprecatedUsage
333        | RuleId::OutdatedLogic
334        | RuleId::LogicGap => scope.implementation_changed || scope.cargo_metadata_changed,
335        RuleId::ConstraintViolation => scope.implementation_changed,
336        RuleId::GhostCommand => scope.cargo_metadata_changed,
337        RuleId::MissingCoverage | RuleId::LyingTest | RuleId::EnvMismatch => false,
338    }
339}
340
341fn is_rust_implementation_path(root: &Path, path: &Path) -> bool {
342    if path.extension().and_then(|e| e.to_str()) != Some("rs") {
343        return false;
344    }
345
346    let rel = path.strip_prefix(root).unwrap_or(path);
347    !rel.components()
348        .any(|c| c.as_os_str() == "tests" || c.as_os_str() == "examples")
349}
350
351fn is_cargo_manifest_path(path: &Path) -> bool {
352    path.file_name().and_then(|n| n.to_str()) == Some("Cargo.toml")
353}
354
355/// Build the analyzer list for the selected pillar(s), wiring the LLM client
356/// once so it is shared across all LLM-backed analyzers.
357fn build_analyzers(pillar: Pillar, config: &Config, no_llm: bool) -> Vec<Box<dyn DriftAnalyzer>> {
358    let docs = matches!(pillar, Pillar::All | Pillar::Docs);
359    let examples = matches!(pillar, Pillar::All | Pillar::Examples);
360    let tests = matches!(pillar, Pillar::All | Pillar::Tests);
361    let ci = matches!(pillar, Pillar::All | Pillar::Ci);
362
363    let llm_client = llm::build_client(&config.llm, no_llm);
364
365    let mut v: Vec<Box<dyn DriftAnalyzer>> = Vec::new();
366    if docs {
367        v.push(Box::new(analyzers::DocsAnalyzer::default()));
368        v.push(Box::new(analyzers::MissingCoverageAnalyzer));
369        if !config.constraint_rules.is_empty() {
370            v.push(Box::new(analyzers::ConstraintAnalyzer::new(
371                config.constraint_rules.clone(),
372            )));
373        }
374        v.push(Box::new(analyzers::OutdatedLogicAnalyzer::new(
375            llm_client.clone(),
376        )));
377    }
378    if examples {
379        v.push(Box::new(analyzers::ExamplesAnalyzer::default()));
380        v.push(Box::new(analyzers::DeprecatedUsageAnalyzer::default()));
381        v.push(Box::new(analyzers::LogicGapAnalyzer::new(
382            llm_client.clone(),
383        )));
384    }
385    if tests {
386        v.push(Box::new(analyzers::TestsAnalyzer));
387    }
388    if ci {
389        v.push(Box::new(analyzers::CiAnalyzer::default()));
390        v.push(Box::new(analyzers::EnvMismatchAnalyzer));
391    }
392    v
393}
394
395#[allow(clippy::too_many_arguments)]
396fn apply_nondeterministic_fixes(
397    root: &Path,
398    analysis_root: &Path,
399    files: &sources::DiscoveredFiles,
400    pillar: Pillar,
401    config: &Config,
402    no_llm: bool,
403    divergences: &[Divergence],
404    llm_client: &std::sync::Arc<dyn llm::LlmClient>,
405    ctx: &ProjectContext,
406) -> anyhow::Result<u32> {
407    let mut applied = 0;
408
409    for d in divergences {
410        if d.rule == RuleId::OutdatedLogic {
411            let abs_path = root.join(&d.location.file);
412            if let Some((original_section, range)) =
413                auto_fix::slice_markdown_section(&abs_path, d.location.line)
414            {
415                let code_context = auto_fix::build_outdated_logic_context(ctx, &original_section);
416                let (system, user) = auto_fix::build_markdown_correction_prompt(
417                    &original_section,
418                    &code_context,
419                    &d.reality,
420                );
421
422                eprintln!(
423                    "  Coherency Auto-Correction (OutdatedLogic): {}",
424                    d.location.file.display()
425                );
426                if let Some(corrected) = llm_client.complete(&system, &user) {
427                    let corrected = corrected.trim().to_string();
428                    if !corrected.is_empty() {
429                        let original_content = std::fs::read_to_string(&abs_path)?;
430                        let mut new_content = original_content.clone();
431                        new_content.replace_range(range.clone(), &corrected);
432
433                        // Transaction: write new content
434                        std::fs::write(&abs_path, &new_content)?;
435
436                        // Validation sweep
437                        if run_validation_sweep(root, analysis_root, files, pillar, config, no_llm)
438                        {
439                            eprintln!("    [OK] Passed validation!");
440                            applied += 1;
441                        } else {
442                            eprintln!(
443                                "    [ROLLBACK] Validation failed (compile error or critical regression). Rolling back..."
444                            );
445                            std::fs::write(&abs_path, &original_content)?;
446                        }
447                    }
448                }
449            }
450        } else if d.rule == RuleId::LogicGap {
451            let abs_path = root.join(&d.location.file);
452            if let Some((original_narrative, range)) = auto_fix::slice_example_narrative(&abs_path)
453            {
454                let public_signatures = auto_fix::collect_public_signatures(ctx);
455                let (system, user) = auto_fix::build_example_narrative_prompt(
456                    &original_narrative,
457                    &public_signatures,
458                    &d.reality,
459                );
460
461                eprintln!(
462                    "  Coherency Auto-Correction (LogicGap): {}",
463                    d.location.file.display()
464                );
465                if let Some(corrected_narrative) = llm_client.complete(&system, &user) {
466                    let original_content = std::fs::read_to_string(&abs_path)?;
467                    let first_line = original_content[range.clone()].lines().next().unwrap_or("");
468                    let prefix = if first_line.trim_start().starts_with("//!") {
469                        "//!"
470                    } else {
471                        "//"
472                    };
473
474                    let formatted_comments =
475                        auto_fix::format_as_comments(&corrected_narrative, prefix);
476                    let mut new_content = original_content.clone();
477                    new_content.replace_range(range.clone(), &formatted_comments);
478
479                    // Transaction: write new content
480                    std::fs::write(&abs_path, &new_content)?;
481
482                    // Validation sweep
483                    if run_validation_sweep(root, analysis_root, files, pillar, config, no_llm) {
484                        eprintln!("    [OK] Passed validation!");
485                        applied += 1;
486                    } else {
487                        eprintln!(
488                            "    [ROLLBACK] Validation failed (compile error or critical regression). Rolling back..."
489                        );
490                        std::fs::write(&abs_path, &original_content)?;
491                    }
492                }
493            }
494        }
495    }
496
497    Ok(applied)
498}
499
500fn run_validation_sweep(
501    root: &Path,
502    analysis_root: &Path,
503    files: &sources::DiscoveredFiles,
504    pillar: Pillar,
505    config: &Config,
506    no_llm: bool,
507) -> bool {
508    let mut val_ctx = ProjectContext::new(root);
509    val_ctx.analysis_root = analysis_root.to_path_buf();
510    val_ctx.rust_files = files.rust.clone();
511    val_ctx.markdown_files = files.markdown.clone();
512    val_ctx.yaml_files = files.yaml.clone();
513    val_ctx.makefile_files = files.makefiles.clone();
514
515    for rs in &val_ctx.rust_files {
516        if let Ok(facts) = parsers::RustParser::parse(rs) {
517            val_ctx.code_facts.extend(facts);
518        }
519    }
520
521    let analyzers = build_analyzers(pillar, config, no_llm);
522    let divergences = run(&val_ctx, &analyzers);
523    let divergences = apply_config(divergences, config, root);
524    let divergences = suppress::apply_inline_ignores(divergences);
525
526    for d in &divergences {
527        if d.rule == RuleId::CompileFailure || d.severity == Severity::Critical {
528            return false;
529        }
530    }
531    true
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537    use crate::domain::{Location, RuleId};
538
539    fn div(path: &str) -> Divergence {
540        Divergence {
541            rule: RuleId::SymbolAbsence,
542            severity: Severity::Critical,
543            location: Location::new(path, 1),
544            stated: "x".into(),
545            reality: "y".into(),
546            risk: "z".into(),
547            attribution: None,
548        }
549    }
550
551    #[test]
552    fn diff_filter_matches_relative_locations_against_changed_absolute_paths() {
553        let root = Path::new("/repo");
554        let scope = DiffScope::new(root, HashSet::from([PathBuf::from("/repo/README.md")]));
555
556        let out = filter_to_diff_scope(vec![div("README.md"), div("src/lib.rs")], root, &scope);
557
558        assert_eq!(out.len(), 1);
559        assert_eq!(out[0].location.file, PathBuf::from("README.md"));
560    }
561
562    #[test]
563    fn diff_filter_keeps_doc_drift_when_implementation_changed() {
564        let root = Path::new("/repo");
565        let scope = DiffScope::new(root, HashSet::from([PathBuf::from("/repo/src/lib.rs")]));
566
567        let out = filter_to_diff_scope(vec![div("README.md")], root, &scope);
568
569        assert_eq!(out.len(), 1);
570        assert_eq!(out[0].rule, RuleId::SymbolAbsence);
571    }
572
573    #[test]
574    fn diff_filter_keeps_ci_drift_when_cargo_manifest_changed() {
575        let root = Path::new("/repo");
576        let scope = DiffScope::new(root, HashSet::from([PathBuf::from("/repo/Cargo.toml")]));
577        let ghost = Divergence {
578            rule: RuleId::GhostCommand,
579            location: Location::new(".github/workflows/ci.yml", 10),
580            ..div(".github/workflows/ci.yml")
581        };
582
583        let out = filter_to_diff_scope(vec![ghost], root, &scope);
584
585        assert_eq!(out.len(), 1);
586        assert_eq!(out[0].rule, RuleId::GhostCommand);
587    }
588
589    #[test]
590    fn normalize_locations_strips_workspace_root() {
591        let root = Path::new("/repo");
592        let out = normalize_locations(
593            vec![div("/repo/README.md"), div("/elsewhere/file.md")],
594            root,
595        );
596
597        assert_eq!(out[0].location.file, PathBuf::from("README.md"));
598        assert_eq!(out[1].location.file, PathBuf::from("/elsewhere/file.md"));
599    }
600
601    struct MockLlmClient {
602        complete_val: String,
603    }
604    impl llm::LlmClient for MockLlmClient {
605        fn evaluate(&self, _: &str, _: &str) -> Option<crate::llm::LlmVerdict> {
606            None
607        }
608        fn complete(&self, _: &str, _: &str) -> Option<String> {
609            Some(self.complete_val.clone())
610        }
611    }
612
613    #[test]
614    fn test_nondeterministic_fixes_transaction_success_and_rollback() {
615        let tmp = tempfile::tempdir().unwrap();
616        let root = tmp.path();
617
618        let readme_path = root.join("README.md");
619        std::fs::write(
620            &readme_path,
621            "## Section 1\nUse `old_function()` to start.\n",
622        )
623        .unwrap();
624
625        let lib_path = root.join("src/lib.rs");
626        std::fs::create_dir_all(root.join("src")).unwrap();
627        std::fs::write(&lib_path, "pub fn new_function() {}\n").unwrap();
628
629        std::fs::write(
630            root.join("Cargo.toml"),
631            "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2024\"\n",
632        )
633        .unwrap();
634
635        let files = sources::DiscoveredFiles {
636            rust: vec![lib_path.clone()],
637            markdown: vec![readme_path.clone()],
638            yaml: vec![],
639            makefiles: vec![],
640        };
641
642        let mut ctx = ProjectContext::new(root);
643        ctx.analysis_root = root.to_path_buf();
644        ctx.rust_files = vec![lib_path.clone()];
645        ctx.markdown_files = vec![readme_path.clone()];
646        ctx.code_facts = crate::parsers::RustParser::parse(&lib_path).unwrap();
647
648        let divergences = vec![Divergence {
649            rule: RuleId::OutdatedLogic,
650            severity: Severity::Notice,
651            location: Location::new("README.md", 1),
652            stated: "section describes current behavior".into(),
653            reality: "Outdated!".into(),
654            risk: "Docs teach behavior the code no longer implements.".into(),
655            attribution: None,
656        }];
657
658        let mock_client_success: std::sync::Arc<dyn llm::LlmClient> =
659            std::sync::Arc::new(MockLlmClient {
660                complete_val: "## Section 1\nUse `new_function()` to start.\n".to_string(),
661            });
662
663        let config = Config::default();
664
665        let applied_success = apply_nondeterministic_fixes(
666            root,
667            root,
668            &files,
669            Pillar::Docs,
670            &config,
671            false,
672            &divergences,
673            &mock_client_success,
674            &ctx,
675        )
676        .unwrap();
677
678        assert_eq!(applied_success, 1);
679        let content_success = std::fs::read_to_string(&readme_path).unwrap();
680        assert!(content_success.contains("new_function"));
681
682        std::fs::write(
683            &readme_path,
684            "## Section 1\nUse `old_function()` to start.\n",
685        )
686        .unwrap();
687
688        let mock_client_fail: std::sync::Arc<dyn llm::LlmClient> =
689            std::sync::Arc::new(MockLlmClient {
690                complete_val: "## Section 1\nUse `missing_function()` to start.\n".to_string(),
691            });
692
693        let applied_fail = apply_nondeterministic_fixes(
694            root,
695            root,
696            &files,
697            Pillar::Docs,
698            &config,
699            false,
700            &divergences,
701            &mock_client_fail,
702            &ctx,
703        )
704        .unwrap();
705
706        assert_eq!(applied_fail, 0);
707        let content_fail = std::fs::read_to_string(&readme_path).unwrap();
708        assert!(content_fail.contains("old_function"));
709        assert!(!content_fail.contains("missing_function"));
710    }
711}