Skip to main content

kranz_engine/
knowledge.rs

1//! Ranked, byte-capped `docs/knowledge/` injection for planning seeds (slice 2).
2//!
3//! Separate from the lessons budget ([`crate::lessons::LESSONS_INJECT_MAX_BYTES`]).
4//! Missing vault → inject nothing. Stale notes and notes without
5//! `verified_against` are excluded from automatic injection.
6//!
7//! Boundary note (positioning ADR, frozen surface): context-MANAGEMENT
8//! features — retrieval pipelines, memory systems, dynamic context
9//! optimization — are frozen; this is a capped, ranked, auditable
10//! injection, deliberately not a context engine
11//! (docs/knowledge/decisions/positioning-governance-evidence-layer.md).
12
13use crate::git_ops::GitRepo;
14use chrono::NaiveDate;
15use serde::Serialize;
16use std::path::{Component, Path, PathBuf};
17
18/// Hard cap (bytes) on the rendered knowledge block — D-C / ticket.
19pub const KNOWLEDGE_INJECT_MAX_BYTES: usize = 4096;
20
21const HEADER: &str = "## Knowledge from this repo\n\n";
22
23/// Inputs that drive note ranking for a planning or revised-planning seed.
24#[derive(Debug, Clone, Default)]
25pub struct KnowledgeQuery<'a> {
26    pub goal: &'a str,
27    pub ticket_body: Option<&'a str>,
28    pub touch_hints: &'a [String],
29    pub changed_files: &'a [String],
30}
31
32/// Render a ≤4 KiB knowledge block, or `None` when the vault is missing/empty
33/// of injectable content.
34pub fn render_knowledge_for_planning(
35    repo_root: &Path,
36    query: &KnowledgeQuery<'_>,
37) -> Option<String> {
38    let vault = repo_root.join("docs").join("knowledge");
39    if !vault.is_dir() {
40        return None;
41    }
42
43    let mut out = String::with_capacity(KNOWLEDGE_INJECT_MAX_BYTES);
44    out.push_str(HEADER);
45
46    // 1) Truncated map from index.md (headings / top-level map bullets).
47    // The map itself may be truncated to fit; notes below are whole-or-skip.
48    if let Some(map) = render_index_map(&vault) {
49        push_truncated(&mut out, &map);
50    }
51    if out.len() >= KNOWLEDGE_INJECT_MAX_BYTES {
52        return Some(truncate_to_bytes(&out, KNOWLEDGE_INJECT_MAX_BYTES));
53    }
54
55    let mut notes = collect_notes(repo_root, &vault);
56    if notes.is_empty() && out.trim() == HEADER.trim() {
57        return None;
58    }
59
60    let haystack = {
61        let mut s = query.goal.to_ascii_lowercase();
62        if let Some(body) = query.ticket_body {
63            s.push('\n');
64            s.push_str(&body.to_ascii_lowercase());
65        }
66        s
67    };
68    let path_hints: Vec<String> = query
69        .touch_hints
70        .iter()
71        .chain(query.changed_files.iter())
72        .map(|p| p.replace('\\', "/"))
73        .collect();
74
75    // Tier 2: explicitly referenced by goal/ticket (path or title).
76    // Tier 3: verified_against overlaps touch/changed hints.
77    let mut tier2 = Vec::new();
78    let mut tier3 = Vec::new();
79    for note in notes.drain(..) {
80        if note_referenced(&note, &haystack) {
81            tier2.push(note);
82        } else if note_overlaps_paths(&note, &path_hints) {
83            tier3.push(note);
84        }
85    }
86    // Stable order within a tier: path lexicographic.
87    tier2.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
88    tier3.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
89
90    for note in tier2.into_iter().chain(tier3) {
91        if out.len() >= KNOWLEDGE_INJECT_MAX_BYTES {
92            break;
93        }
94        let block = format_note_excerpt(&note);
95        if !push_budgeted(&mut out, &block) {
96            break;
97        }
98    }
99
100    let trimmed = out.trim_end();
101    if trimmed == HEADER.trim() || trimmed.is_empty() {
102        None
103    } else {
104        Some(truncate_to_bytes(trimmed, KNOWLEDGE_INJECT_MAX_BYTES))
105    }
106}
107
108/// One citation verdict from [`refresh_knowledge`].
109#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
110#[serde(rename_all = "kebab-case", tag = "verdict")]
111pub enum RefreshVerdict {
112    Ok,
113    AlreadyStale,
114    Unverified,
115    InvalidMetadata {
116        field: String,
117        value: Option<String>,
118    },
119    InvalidCitation {
120        citation: String,
121    },
122    PathMissing {
123        path: String,
124    },
125    PathDrifted {
126        path: String,
127    },
128    CommandSkipped {
129        command: String,
130    },
131    ProbeFailed {
132        target: String,
133        error: String,
134    },
135}
136
137impl RefreshVerdict {
138    /// Findings that should fail `kranz knowledge refresh` (exit 1).
139    /// `already-stale` and `command-skipped` are reported but do not fail.
140    pub fn is_check_needed(&self) -> bool {
141        matches!(
142            self,
143            Self::Unverified
144                | Self::InvalidMetadata { .. }
145                | Self::InvalidCitation { .. }
146                | Self::PathMissing { .. }
147                | Self::PathDrifted { .. }
148                | Self::ProbeFailed { .. }
149        )
150    }
151}
152
153/// One vault note's refresh result.
154#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
155#[serde(rename_all = "camelCase")]
156pub struct RefreshFinding {
157    pub rel_path: String,
158    pub title: String,
159    pub freshness: String,
160    pub verdicts: Vec<RefreshVerdict>,
161}
162
163/// Report-only drift check over `docs/knowledge/` (slice 3).
164#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
165#[serde(rename_all = "camelCase")]
166pub struct RefreshReport {
167    pub findings: Vec<RefreshFinding>,
168}
169
170impl RefreshReport {
171    pub fn check_needed(&self) -> bool {
172        self.findings
173            .iter()
174            .any(|f| f.verdicts.iter().any(RefreshVerdict::is_check_needed))
175    }
176}
177
178/// Walk the vault and report drifted / missing / unverified citations.
179///
180/// Never rewrites a note. Never runs a `verified_against` entry prefixed with
181/// `command:`; commands are reported as `command-skipped`. Every other entry
182/// is a path (an optional `path:` prefix is accepted), including paths with
183/// spaces. Path drift uses
184/// [`GitRepo::path_changed_since`] when the repo is git and the note has
185/// `last_verified`.
186pub fn refresh_knowledge(repo_root: &Path) -> RefreshReport {
187    let vault = repo_root.join("docs").join("knowledge");
188    let git = GitRepo::open(repo_root).map_err(|err| err.to_string());
189    let mut findings = Vec::new();
190    if !vault.is_dir() {
191        return RefreshReport { findings };
192    }
193    for source in collect_notes_for_refresh(repo_root, &vault) {
194        match source {
195            RefreshSource::Note(note) => findings.push(refresh_one(&note, repo_root, &git)),
196            RefreshSource::Finding(finding) => findings.push(finding),
197        }
198    }
199    findings.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
200    RefreshReport { findings }
201}
202
203fn refresh_one(
204    note: &KnowledgeNote,
205    repo_root: &Path,
206    git: &std::result::Result<GitRepo, String>,
207) -> RefreshFinding {
208    let mut verdicts = Vec::new();
209    if note.freshness.eq_ignore_ascii_case("stale") {
210        verdicts.push(RefreshVerdict::AlreadyStale);
211        return RefreshFinding {
212            rel_path: note.rel_path.clone(),
213            title: note.title.clone(),
214            freshness: note.freshness.clone(),
215            verdicts,
216        };
217    }
218    if note.verified_against.is_empty() {
219        verdicts.push(RefreshVerdict::Unverified);
220        return RefreshFinding {
221            rel_path: note.rel_path.clone(),
222            title: note.title.clone(),
223            freshness: note.freshness.clone(),
224            verdicts,
225        };
226    }
227    let Some(since) = validated_last_verified(note, &mut verdicts) else {
228        return RefreshFinding {
229            rel_path: note.rel_path.clone(),
230            title: note.title.clone(),
231            freshness: note.freshness.clone(),
232            verdicts,
233        };
234    };
235    for citation in &note.verified_against {
236        verdicts.push(refresh_citation(citation, &since, repo_root, git));
237    }
238    RefreshFinding {
239        rel_path: note.rel_path.clone(),
240        title: note.title.clone(),
241        freshness: note.freshness.clone(),
242        verdicts,
243    }
244}
245
246fn validated_last_verified(
247    note: &KnowledgeNote,
248    verdicts: &mut Vec<RefreshVerdict>,
249) -> Option<String> {
250    let Some(value) = note.last_verified.as_deref() else {
251        verdicts.push(RefreshVerdict::InvalidMetadata {
252            field: "last_verified".into(),
253            value: None,
254        });
255        return None;
256    };
257    let Ok(date) = NaiveDate::parse_from_str(value, "%Y-%m-%d") else {
258        verdicts.push(RefreshVerdict::InvalidMetadata {
259            field: "last_verified".into(),
260            value: Some(value.to_string()),
261        });
262        return None;
263    };
264    Some(date.format("%Y-%m-%d").to_string())
265}
266
267fn refresh_citation(
268    citation: &str,
269    since: &str,
270    repo_root: &Path,
271    git: &std::result::Result<GitRepo, String>,
272) -> RefreshVerdict {
273    let citation = citation.trim();
274    if let Some(command) = citation.strip_prefix("command:") {
275        let command = command.trim();
276        return if command.is_empty() {
277            RefreshVerdict::InvalidCitation {
278                citation: citation.to_string(),
279            }
280        } else {
281            RefreshVerdict::CommandSkipped {
282                command: command.to_string(),
283            }
284        };
285    }
286    let path = citation
287        .strip_prefix("path:")
288        .map(str::trim)
289        .unwrap_or(citation);
290    if !citation_is_repo_relative(path) {
291        return RefreshVerdict::InvalidCitation {
292            citation: citation.to_string(),
293        };
294    }
295
296    match citation_exists_inside_repo(repo_root, path) {
297        Ok(false) => {
298            return RefreshVerdict::PathMissing {
299                path: path.to_string(),
300            };
301        }
302        Ok(true) => {}
303        Err(verdict) => return verdict,
304    }
305
306    let git = match git {
307        Ok(git) => git,
308        Err(error) => {
309            return RefreshVerdict::ProbeFailed {
310                target: path.to_string(),
311                error: error.clone(),
312            };
313        }
314    };
315    match git.path_changed_since(path, since) {
316        Ok(true) => RefreshVerdict::PathDrifted {
317            path: path.to_string(),
318        },
319        Ok(false) => RefreshVerdict::Ok,
320        Err(error) => RefreshVerdict::ProbeFailed {
321            target: path.to_string(),
322            error: error.to_string(),
323        },
324    }
325}
326
327fn citation_is_repo_relative(citation: &str) -> bool {
328    !citation.is_empty()
329        && Path::new(citation)
330            .components()
331            .all(|component| matches!(component, Component::Normal(_) | Component::CurDir))
332}
333
334fn citation_exists_inside_repo(
335    repo_root: &Path,
336    citation: &str,
337) -> std::result::Result<bool, RefreshVerdict> {
338    let path = repo_root.join(citation);
339    match std::fs::symlink_metadata(&path) {
340        Ok(_) => {}
341        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false),
342        Err(err) => {
343            return Err(RefreshVerdict::ProbeFailed {
344                target: citation.to_string(),
345                error: err.to_string(),
346            });
347        }
348    }
349
350    let canonical_root =
351        std::fs::canonicalize(repo_root).map_err(|err| RefreshVerdict::ProbeFailed {
352            target: citation.to_string(),
353            error: err.to_string(),
354        })?;
355    let canonical_path =
356        std::fs::canonicalize(&path).map_err(|err| RefreshVerdict::ProbeFailed {
357            target: citation.to_string(),
358            error: err.to_string(),
359        })?;
360    if !canonical_path.starts_with(canonical_root) {
361        return Err(RefreshVerdict::InvalidCitation {
362            citation: citation.to_string(),
363        });
364    }
365    Ok(true)
366}
367
368#[derive(Debug, Clone)]
369struct KnowledgeNote {
370    /// Path relative to repo root, forward slashes (e.g. `docs/knowledge/foo.md`).
371    rel_path: String,
372    title: String,
373    freshness: String,
374    last_verified: Option<String>,
375    verified_against: Vec<String>,
376    body: String,
377}
378
379fn collect_notes(repo_root: &Path, vault: &Path) -> Vec<KnowledgeNote> {
380    let mut out = Vec::new();
381    let Ok(walker) = walkdir_md(vault) else {
382        return out;
383    };
384    for path in walker {
385        let name = path
386            .file_name()
387            .and_then(|n| n.to_str())
388            .unwrap_or_default();
389        if name.eq_ignore_ascii_case("index.md") || name.eq_ignore_ascii_case("CONVENTIONS.md") {
390            continue;
391        }
392        let Ok(text) = std::fs::read_to_string(&path) else {
393            continue;
394        };
395        let Some(note) = parse_note(&path, repo_root, &text) else {
396            continue;
397        };
398        // Exclude stale and notes without verified_against.
399        if note.freshness.eq_ignore_ascii_case("stale") || note.verified_against.is_empty() {
400            continue;
401        }
402        out.push(note);
403    }
404    out
405}
406
407enum RefreshSource {
408    Note(KnowledgeNote),
409    Finding(RefreshFinding),
410}
411
412/// All notes, including stale, unverified, and malformed sources (refresh must
413/// report anything it cannot inspect). Still skips `CONVENTIONS.md` (format
414/// doc, not a fact note).
415fn collect_notes_for_refresh(repo_root: &Path, vault: &Path) -> Vec<RefreshSource> {
416    let mut out = Vec::new();
417    let walker = match walkdir_md(vault) {
418        Ok(walker) => walker,
419        Err(err) => {
420            return vec![RefreshSource::Finding(refresh_problem(
421                "docs/knowledge".into(),
422                "Knowledge vault".into(),
423                RefreshVerdict::ProbeFailed {
424                    target: "docs/knowledge".into(),
425                    error: err.to_string(),
426                },
427            ))];
428        }
429    };
430    for path in walker {
431        let name = path
432            .file_name()
433            .and_then(|n| n.to_str())
434            .unwrap_or_default();
435        if name.eq_ignore_ascii_case("CONVENTIONS.md") {
436            continue;
437        }
438        let rel_path = path
439            .strip_prefix(repo_root)
440            .unwrap_or(&path)
441            .to_string_lossy()
442            .replace('\\', "/");
443        let title = path
444            .file_stem()
445            .and_then(|stem| stem.to_str())
446            .unwrap_or("knowledge note")
447            .to_string();
448        let text = match std::fs::read_to_string(&path) {
449            Ok(text) => text,
450            Err(err) => {
451                out.push(RefreshSource::Finding(refresh_problem(
452                    rel_path.clone(),
453                    title,
454                    RefreshVerdict::ProbeFailed {
455                        target: rel_path,
456                        error: err.to_string(),
457                    },
458                )));
459                continue;
460            }
461        };
462        let Some(note) = parse_note(&path, repo_root, &text) else {
463            out.push(RefreshSource::Finding(refresh_problem(
464                rel_path,
465                title,
466                RefreshVerdict::InvalidMetadata {
467                    field: "frontmatter".into(),
468                    value: None,
469                },
470            )));
471            continue;
472        };
473        out.push(RefreshSource::Note(note));
474    }
475    out
476}
477
478fn refresh_problem(rel_path: String, title: String, verdict: RefreshVerdict) -> RefreshFinding {
479    RefreshFinding {
480        rel_path,
481        title,
482        freshness: "unknown".into(),
483        verdicts: vec![verdict],
484    }
485}
486
487fn walkdir_md(vault: &Path) -> std::io::Result<Vec<PathBuf>> {
488    let mut files = Vec::new();
489    fn walk(dir: &Path, files: &mut Vec<PathBuf>) -> std::io::Result<()> {
490        for entry in std::fs::read_dir(dir)? {
491            let entry = entry?;
492            let path = entry.path();
493            let ft = entry.file_type()?;
494            if ft.is_dir() {
495                walk(&path, files)?;
496            } else if ft.is_file()
497                && path
498                    .extension()
499                    .and_then(|e| e.to_str())
500                    .is_some_and(|e| e.eq_ignore_ascii_case("md"))
501            {
502                files.push(path);
503            }
504        }
505        Ok(())
506    }
507    walk(vault, &mut files)?;
508    files.sort();
509    Ok(files)
510}
511
512fn parse_note(path: &Path, repo_root: &Path, text: &str) -> Option<KnowledgeNote> {
513    let (fm, body) = parse_knowledge_frontmatter(text)?;
514    let title = fm.get("title").cloned().unwrap_or_else(|| {
515        path.file_stem()
516            .and_then(|s| s.to_str())
517            .unwrap_or("note")
518            .to_string()
519    });
520    let freshness = fm
521        .get("freshness")
522        .cloned()
523        .unwrap_or_else(|| "check-on-touch".into());
524    let last_verified = fm.get("last_verified").cloned().filter(|s| !s.is_empty());
525    let verified_against = fm
526        .get("verified_against")
527        .map(|s| {
528            s.split('\n')
529                .map(str::trim)
530                .filter(|s| !s.is_empty())
531                .map(str::to_string)
532                .collect::<Vec<_>>()
533        })
534        .unwrap_or_default();
535
536    let rel_path = path
537        .strip_prefix(repo_root)
538        .unwrap_or(path)
539        .to_string_lossy()
540        .replace('\\', "/");
541
542    Some(KnowledgeNote {
543        rel_path,
544        title,
545        freshness,
546        last_verified,
547        verified_against,
548        body,
549    })
550}
551
552/// Minimal frontmatter parser supporting scalars and YAML `- item` lists.
553/// Returns `None` when frontmatter is missing/malformed (note skipped).
554fn parse_knowledge_frontmatter(
555    text: &str,
556) -> Option<(std::collections::BTreeMap<String, String>, String)> {
557    let source = text.trim_start_matches('\u{feff}');
558    let first_end = source.find('\n').map(|i| i + 1).unwrap_or(source.len());
559    if source[..first_end].trim_end() != "---" {
560        return None;
561    }
562    let mut map = std::collections::BTreeMap::new();
563    let mut offset = first_end;
564    let mut list_key: Option<String> = None;
565    let mut list_items: Vec<String> = Vec::new();
566
567    let flush_list = |map: &mut std::collections::BTreeMap<String, String>,
568                      list_key: &mut Option<String>,
569                      list_items: &mut Vec<String>| {
570        if let Some(k) = list_key.take() {
571            map.insert(k, list_items.join("\n"));
572            list_items.clear();
573        }
574    };
575
576    while offset < source.len() {
577        let rest = &source[offset..];
578        let line_len = rest.find('\n').map(|i| i + 1).unwrap_or(rest.len());
579        let raw = &rest[..line_len];
580        if raw.trim_end() == "---" {
581            flush_list(&mut map, &mut list_key, &mut list_items);
582            let body = source[offset + line_len..].to_string();
583            return Some((map, body));
584        }
585        let line = raw.trim_end();
586        if let Some(item) = line.strip_prefix("  - ").or_else(|| {
587            if line.starts_with("- ") && list_key.is_some() {
588                Some(&line[2..])
589            } else {
590                None
591            }
592        }) {
593            // Continuation of a YAML list under the current key.
594            if list_key.is_some() {
595                let item = item.trim().trim_matches('"').trim_matches('\'').to_string();
596                if !item.is_empty() {
597                    list_items.push(item);
598                }
599            }
600        } else if let Some((key, value)) = line.split_once(':') {
601            flush_list(&mut map, &mut list_key, &mut list_items);
602            let key = key.trim().to_ascii_lowercase();
603            let value = value.trim().trim_matches('"').trim_matches('\'');
604            if value.is_empty() {
605                list_key = Some(key);
606            } else if let Some(inner) = value.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
607                let joined = inner
608                    .split(',')
609                    .map(|s| s.trim().trim_matches('"').trim_matches('\'').to_string())
610                    .filter(|s| !s.is_empty())
611                    .collect::<Vec<_>>()
612                    .join("\n");
613                map.insert(key, joined);
614            } else {
615                map.insert(key, value.to_string());
616            }
617        } else if !line.trim().is_empty() {
618            // Non-key prose inside frontmatter ends list accumulation.
619            flush_list(&mut map, &mut list_key, &mut list_items);
620        }
621        offset += line_len;
622    }
623    None
624}
625
626fn render_index_map(vault: &Path) -> Option<String> {
627    let text = std::fs::read_to_string(vault.join("index.md")).ok()?;
628    let (_, body) = parse_knowledge_frontmatter(&text).unwrap_or((Default::default(), text));
629    let mut map = String::from("### Vault map\n");
630    let mut in_map = false;
631    for line in body.lines() {
632        let t = line.trim();
633        if t.eq_ignore_ascii_case("## Map") || t.eq_ignore_ascii_case("## map") {
634            in_map = true;
635            continue;
636        }
637        if in_map && t.starts_with("## ") {
638            break;
639        }
640        if in_map && (t.starts_with("### ") || t.starts_with("- ") || t.starts_with("* ")) {
641            map.push_str(line.trim_end());
642            map.push('\n');
643        }
644    }
645    if map.trim() == "### Vault map" {
646        // Fall back: first ~30 non-empty body lines as a sketch.
647        map.clear();
648        map.push_str("### Vault map\n");
649        for line in body.lines().filter(|l| !l.trim().is_empty()).take(30) {
650            map.push_str(line.trim_end());
651            map.push('\n');
652        }
653    }
654    Some(map)
655}
656
657fn note_referenced(note: &KnowledgeNote, haystack_lower: &str) -> bool {
658    let path_l = note.rel_path.to_ascii_lowercase();
659    // Prefer explicit path references (full or vault-relative).
660    if !path_l.is_empty() && haystack_lower.contains(&path_l) {
661        return true;
662    }
663    if let Some(under) = path_l.strip_prefix("docs/knowledge/") {
664        if under.len() >= 6 && haystack_lower.contains(under) {
665            return true;
666        }
667    }
668    // Basename only as a whole token (avoid "plan" matching every goal).
669    if let Some(base) = Path::new(&note.rel_path)
670        .file_stem()
671        .and_then(|s| s.to_str())
672    {
673        let base_l = base.to_ascii_lowercase();
674        if base_l.len() >= 6 && contains_word(haystack_lower, &base_l) {
675            return true;
676        }
677    }
678    let title_l = note.title.to_ascii_lowercase();
679    if title_l.len() >= 8 && contains_word(haystack_lower, &title_l) {
680        return true;
681    }
682    false
683}
684
685fn contains_word(haystack: &str, needle: &str) -> bool {
686    if needle.is_empty() {
687        return false;
688    }
689    for (i, _) in haystack.match_indices(needle) {
690        let before_ok = i == 0
691            || !haystack
692                .as_bytes()
693                .get(i - 1)
694                .is_some_and(|b| b.is_ascii_alphanumeric() || *b == b'_' || *b == b'-');
695        let after = i + needle.len();
696        let after_ok = after >= haystack.len()
697            || !haystack
698                .as_bytes()
699                .get(after)
700                .is_some_and(|b| b.is_ascii_alphanumeric() || *b == b'_' || *b == b'-');
701        if before_ok && after_ok {
702            return true;
703        }
704    }
705    false
706}
707
708fn note_overlaps_paths(note: &KnowledgeNote, hints: &[String]) -> bool {
709    if hints.is_empty() {
710        return false;
711    }
712    for v in &note.verified_against {
713        if v.trim_start().starts_with("command:") {
714            continue;
715        }
716        let v_norm = v
717            .trim()
718            .strip_prefix("path:")
719            .map(str::trim)
720            .unwrap_or(v.trim())
721            .replace('\\', "/");
722        for h in hints {
723            if v_norm == *h || v_norm.ends_with(h) || h.ends_with(&v_norm) || h.contains(&v_norm) {
724                return true;
725            }
726        }
727    }
728    false
729}
730
731fn format_note_excerpt(note: &KnowledgeNote) -> String {
732    let mut body_lines = note
733        .body
734        .lines()
735        .map(str::trim)
736        .filter(|l| !l.is_empty())
737        .take(12);
738    let mut excerpt = String::new();
739    for line in body_lines.by_ref() {
740        if excerpt.len() > 600 {
741            break;
742        }
743        excerpt.push_str(line);
744        excerpt.push('\n');
745    }
746    let verified = note.verified_against.join(", ");
747    format!(
748        "### {} (`{}`)\nfreshness: {} · verified_against: {}\n{}\n",
749        note.title, note.rel_path, note.freshness, verified, excerpt
750    )
751}
752
753/// Push `chunk` only if it fits entirely under the remaining budget.
754/// Prefer skipping a lower-ranked note over truncating it mid-body (D-C).
755fn push_budgeted(out: &mut String, chunk: &str) -> bool {
756    let remaining = KNOWLEDGE_INJECT_MAX_BYTES.saturating_sub(out.len());
757    if remaining == 0 || chunk.len() > remaining {
758        return false;
759    }
760    out.push_str(chunk);
761    true
762}
763
764/// Push as much of `chunk` as fits (used for the vault map only).
765fn push_truncated(out: &mut String, chunk: &str) {
766    let remaining = KNOWLEDGE_INJECT_MAX_BYTES.saturating_sub(out.len());
767    if remaining == 0 {
768        return;
769    }
770    if chunk.len() <= remaining {
771        out.push_str(chunk);
772    } else {
773        out.push_str(&truncate_to_bytes(chunk, remaining));
774    }
775}
776
777fn truncate_to_bytes(s: &str, limit: usize) -> String {
778    if s.len() <= limit {
779        return s.to_string();
780    }
781    let mut end = limit;
782    while end > 0 && !s.is_char_boundary(end) {
783        end -= 1;
784    }
785    s[..end].to_string()
786}
787
788#[cfg(test)]
789mod tests {
790    use super::*;
791    use std::fs;
792
793    fn write_note(dir: &Path, rel: &str, freshness: &str, verified: &[&str], body: &str) {
794        let path = dir.join(rel);
795        if let Some(parent) = path.parent() {
796            fs::create_dir_all(parent).unwrap();
797        }
798        let mut va = String::from("verified_against:\n");
799        for v in verified {
800            va.push_str(&format!("  - {v}\n"));
801        }
802        let text = format!(
803            "---\ntitle: {rel}\nowner: agent\nfreshness: {freshness}\nlast_verified: 2099-01-01\n{va}---\n\n{body}\n"
804        );
805        fs::write(path, text).unwrap();
806    }
807
808    fn vault(root: &Path) {
809        let v = root.join("docs/knowledge");
810        fs::create_dir_all(v.join("architecture")).unwrap();
811        fs::write(
812            v.join("index.md"),
813            "---\ntitle: index\nfreshness: live\nlast_verified: 2099-01-01\nverified_against:\n  - AGENTS.md\n---\n\n# Vault\n\n## Map\n\n### architecture/\n- [Pipe](architecture/pipe.md)\n",
814        )
815        .unwrap();
816        fs::write(v.join("CONVENTIONS.md"), "# conventions\n").unwrap();
817    }
818
819    #[test]
820    fn missing_vault_returns_none() {
821        let tmp = tempfile::tempdir().unwrap();
822        assert!(render_knowledge_for_planning(tmp.path(), &KnowledgeQuery::default()).is_none());
823    }
824
825    #[test]
826    fn stale_and_unverified_notes_excluded() {
827        let tmp = tempfile::tempdir().unwrap();
828        vault(tmp.path());
829        write_note(
830            &tmp.path().join("docs/knowledge"),
831            "architecture/stale.md",
832            "stale",
833            &["crates/engine/src/foo.rs"],
834            "Should not inject.",
835        );
836        write_note(
837            &tmp.path().join("docs/knowledge"),
838            "architecture/bare.md",
839            "live",
840            &[],
841            "No verified_against.",
842        );
843        write_note(
844            &tmp.path().join("docs/knowledge"),
845            "architecture/pipe.md",
846            "live",
847            &["crates/engine/src/orchestrator.rs"],
848            "Good note about the pipeline.",
849        );
850        let q = KnowledgeQuery {
851            goal: "read architecture/pipe.md please",
852            ..Default::default()
853        };
854        let block = render_knowledge_for_planning(tmp.path(), &q).expect("block");
855        assert!(block.contains("architecture/pipe.md"), "{block}");
856        assert!(!block.contains("Should not inject"), "{block}");
857        assert!(!block.contains("No verified_against"), "{block}");
858        assert!(
859            block.contains("Vault map") || block.contains("architecture/"),
860            "{block}"
861        );
862    }
863
864    #[test]
865    fn respects_byte_budget() {
866        let tmp = tempfile::tempdir().unwrap();
867        vault(tmp.path());
868        let big = "x".repeat(3000);
869        for i in 0..5 {
870            write_note(
871                &tmp.path().join("docs/knowledge"),
872                &format!("architecture/n{i}.md"),
873                "live",
874                &["crates/engine/src/orchestrator.rs"],
875                &format!("note {i} {big}"),
876            );
877        }
878        let q = KnowledgeQuery {
879            goal: "architecture/n0.md architecture/n1.md architecture/n2.md architecture/n3.md architecture/n4.md",
880            ..Default::default()
881        };
882        let block = render_knowledge_for_planning(tmp.path(), &q).expect("block");
883        assert!(
884            block.len() <= KNOWLEDGE_INJECT_MAX_BYTES,
885            "len {} > {}",
886            block.len(),
887            KNOWLEDGE_INJECT_MAX_BYTES
888        );
889    }
890
891    #[test]
892    fn path_overlap_selects_tier3_when_not_named() {
893        let tmp = tempfile::tempdir().unwrap();
894        vault(tmp.path());
895        write_note(
896            &tmp.path().join("docs/knowledge"),
897            "validation/gates.md",
898            "check-on-touch",
899            &["crates/engine/src/merge_gate.rs"],
900            "Gate recipes.",
901        );
902        let hints = vec!["crates/engine/src/merge_gate.rs".into()];
903        let q = KnowledgeQuery {
904            goal: "improve merge flow",
905            touch_hints: &hints,
906            ..Default::default()
907        };
908        let block = render_knowledge_for_planning(tmp.path(), &q).expect("block");
909        assert!(block.contains("validation/gates.md"), "{block}");
910    }
911
912    #[test]
913    fn knowledge_budget_constant_is_separate_from_lessons() {
914        assert_eq!(KNOWLEDGE_INJECT_MAX_BYTES, 4096);
915        assert_ne!(
916            KNOWLEDGE_INJECT_MAX_BYTES,
917            crate::lessons::LESSONS_INJECT_MAX_BYTES
918        );
919    }
920
921    fn git(root: &Path, args: &[&str]) -> std::process::Output {
922        std::process::Command::new("git")
923            .args(args)
924            .current_dir(root)
925            .output()
926            .expect("git")
927    }
928
929    fn git_init(root: &Path) {
930        if !git(root, &["init", "-b", "main"]).status.success() {
931            assert!(git(root, &["init"]).status.success());
932        }
933        assert!(git(root, &["config", "user.name", "kranz-test"])
934            .status
935            .success());
936        assert!(git(root, &["config", "user.email", "test@kranz.local"])
937            .status
938            .success());
939    }
940
941    fn git_commit_all(root: &Path, msg: &str) {
942        assert!(git(root, &["add", "-A"]).status.success());
943        assert!(
944            git(root, &["-c", "commit.gpgsign=false", "commit", "-m", msg])
945                .status
946                .success()
947        );
948    }
949
950    fn git_commit_all_at(root: &Path, msg: &str, timestamp: &str) {
951        assert!(git(root, &["add", "-A"]).status.success());
952        assert!(std::process::Command::new("git")
953            .args(["-c", "commit.gpgsign=false", "commit", "-m", msg])
954            .current_dir(root)
955            .env("GIT_AUTHOR_DATE", timestamp)
956            .env("GIT_COMMITTER_DATE", timestamp)
957            .output()
958            .expect("git commit")
959            .status
960            .success());
961    }
962
963    fn replace_note_date(root: &Path, rel: &str, replacement: Option<&str>) {
964        let path = root.join("docs/knowledge").join(rel);
965        let text = fs::read_to_string(&path).unwrap();
966        let text = match replacement {
967            Some(date) => text.replace(
968                "last_verified: 2099-01-01",
969                &format!("last_verified: {date}"),
970            ),
971            None => text
972                .lines()
973                .filter(|line| !line.starts_with("last_verified:"))
974                .collect::<Vec<_>>()
975                .join("\n"),
976        };
977        fs::write(path, format!("{text}\n")).unwrap();
978    }
979
980    #[test]
981    fn knowledge_refresh_reports_missing_path() {
982        let tmp = tempfile::tempdir().unwrap();
983        fs::write(tmp.path().join("AGENTS.md"), "rules\n").unwrap();
984        vault(tmp.path());
985        write_note(
986            &tmp.path().join("docs/knowledge"),
987            "architecture/ghost.md",
988            "check-on-touch",
989            &["does-not-exist.rs"],
990            "A missing citation.",
991        );
992        let report = refresh_knowledge(tmp.path());
993        assert!(
994            report.check_needed(),
995            "missing path must fail the refresh: {report:?}"
996        );
997        assert!(
998            report.findings.iter().any(|f| {
999                f.rel_path.contains("ghost.md")
1000                    && f.verdicts.iter().any(|v| {
1001                        matches!(
1002                            v,
1003                            RefreshVerdict::PathMissing { path } if path == "does-not-exist.rs"
1004                        )
1005                    })
1006            }),
1007            "{report:?}"
1008        );
1009    }
1010
1011    #[test]
1012    fn knowledge_refresh_skips_commands_and_does_not_fail() {
1013        let tmp = tempfile::tempdir().unwrap();
1014        git_init(tmp.path());
1015        fs::write(tmp.path().join("AGENTS.md"), "rules\n").unwrap();
1016        vault(tmp.path());
1017        write_note(
1018            &tmp.path().join("docs/knowledge"),
1019            "architecture/cmd.md",
1020            "check-on-touch",
1021            &["AGENTS.md", "command: cargo test -p kranz-engine lessons"],
1022            "Path plus a command.",
1023        );
1024        git_commit_all(tmp.path(), "seed");
1025        let report = refresh_knowledge(tmp.path());
1026        let finding = report
1027            .findings
1028            .iter()
1029            .find(|f| f.rel_path.contains("cmd.md"))
1030            .expect("cmd note");
1031        assert!(
1032            finding.verdicts.iter().any(|v| {
1033                matches!(
1034                    v,
1035                    RefreshVerdict::CommandSkipped { command }
1036                        if command.contains("cargo test")
1037                )
1038            }),
1039            "{finding:?}"
1040        );
1041        assert!(
1042            !finding.verdicts.iter().any(RefreshVerdict::is_check_needed),
1043            "command-skipped must not fail: {finding:?}"
1044        );
1045    }
1046
1047    #[test]
1048    fn knowledge_refresh_already_stale_does_not_fail() {
1049        let tmp = tempfile::tempdir().unwrap();
1050        git_init(tmp.path());
1051        fs::write(tmp.path().join("AGENTS.md"), "rules\n").unwrap();
1052        vault(tmp.path());
1053        write_note(
1054            &tmp.path().join("docs/knowledge"),
1055            "architecture/old.md",
1056            "stale",
1057            &[],
1058            "Old news.",
1059        );
1060        git_commit_all(tmp.path(), "seed");
1061        let report = refresh_knowledge(tmp.path());
1062        let finding = report
1063            .findings
1064            .iter()
1065            .find(|f| f.rel_path.contains("old.md"))
1066            .expect("stale note");
1067        assert!(
1068            finding
1069                .verdicts
1070                .iter()
1071                .any(|v| matches!(v, RefreshVerdict::AlreadyStale)),
1072            "{finding:?}"
1073        );
1074        assert!(
1075            !report.check_needed(),
1076            "already-stale alone must not fail: {report:?}"
1077        );
1078    }
1079
1080    #[test]
1081    fn knowledge_refresh_reports_drifted_path() {
1082        let tmp = tempfile::tempdir().unwrap();
1083        git_init(tmp.path());
1084        fs::write(tmp.path().join("AGENTS.md"), "v1\n").unwrap();
1085        vault(tmp.path());
1086        write_note(
1087            &tmp.path().join("docs/knowledge"),
1088            "architecture/drift.md",
1089            "check-on-touch",
1090            &["AGENTS.md"],
1091            "Will drift.",
1092        );
1093        // Backdate last_verified so today's commit is after the check day.
1094        let note_path = tmp.path().join("docs/knowledge/architecture/drift.md");
1095        let text = fs::read_to_string(&note_path).unwrap();
1096        fs::write(
1097            &note_path,
1098            text.replace("last_verified: 2099-01-01", "last_verified: 2020-01-01"),
1099        )
1100        .unwrap();
1101        git_commit_all(tmp.path(), "seed");
1102        fs::write(tmp.path().join("AGENTS.md"), "v2\n").unwrap();
1103        git_commit_all(tmp.path(), "touch agents");
1104        let report = refresh_knowledge(tmp.path());
1105        assert!(
1106            report.check_needed(),
1107            "edited verified path must drift: {report:?}"
1108        );
1109        assert!(
1110            report.findings.iter().any(|f| {
1111                f.rel_path.contains("drift.md")
1112                    && f.verdicts.iter().any(|v| {
1113                        matches!(v, RefreshVerdict::PathDrifted { path } if path == "AGENTS.md")
1114                    })
1115            }),
1116            "{report:?}"
1117        );
1118    }
1119
1120    #[test]
1121    fn knowledge_refresh_reports_unverified_note() {
1122        let tmp = tempfile::tempdir().unwrap();
1123        vault(tmp.path());
1124        write_note(
1125            &tmp.path().join("docs/knowledge"),
1126            "architecture/unverified.md",
1127            "check-on-touch",
1128            &[],
1129            "No citations.",
1130        );
1131
1132        let report = refresh_knowledge(tmp.path());
1133        let finding = report
1134            .findings
1135            .iter()
1136            .find(|finding| finding.rel_path.ends_with("unverified.md"))
1137            .expect("unverified finding");
1138        assert_eq!(finding.verdicts, [RefreshVerdict::Unverified]);
1139        assert!(report.check_needed());
1140    }
1141
1142    /// `kranz knowledge refresh --json` prints this report verbatim, so a field
1143    /// rename here is a CLI contract break. Pin the wire shape.
1144    #[test]
1145    fn knowledge_refresh_report_serializes_verdicts_for_json_output() {
1146        let tmp = tempfile::tempdir().unwrap();
1147        vault(tmp.path());
1148        write_note(
1149            &tmp.path().join("docs/knowledge"),
1150            "architecture/unverified.md",
1151            "check-on-touch",
1152            &[],
1153            "No citations.",
1154        );
1155
1156        let json = serde_json::to_value(refresh_knowledge(tmp.path())).unwrap();
1157        let finding = json["findings"]
1158            .as_array()
1159            .expect("findings array")
1160            .iter()
1161            .find(|finding| {
1162                finding["relPath"]
1163                    .as_str()
1164                    .is_some_and(|rel| rel.ends_with("unverified.md"))
1165            })
1166            .expect("unverified finding");
1167        assert_eq!(finding["freshness"], "check-on-touch");
1168        assert_eq!(
1169            finding["verdicts"],
1170            serde_json::json!([{"verdict": "unverified"}]),
1171            "{json:#}"
1172        );
1173    }
1174
1175    #[test]
1176    fn knowledge_refresh_reports_missing_and_invalid_last_verified() {
1177        let tmp = tempfile::tempdir().unwrap();
1178        fs::write(tmp.path().join("AGENTS.md"), "rules\n").unwrap();
1179        vault(tmp.path());
1180        for (name, replacement, verified) in [
1181            (
1182                "missing-date.md",
1183                None,
1184                "cargo test -p kranz-engine knowledge_refresh",
1185            ),
1186            ("bad-date.md", Some("2026-99-99"), "AGENTS.md"),
1187        ] {
1188            write_note(
1189                &tmp.path().join("docs/knowledge"),
1190                &format!("architecture/{name}"),
1191                "check-on-touch",
1192                &[verified],
1193                "Date metadata matters.",
1194            );
1195            replace_note_date(tmp.path(), &format!("architecture/{name}"), replacement);
1196        }
1197
1198        let report = refresh_knowledge(tmp.path());
1199        for name in ["missing-date.md", "bad-date.md"] {
1200            let finding = report
1201                .findings
1202                .iter()
1203                .find(|finding| finding.rel_path.ends_with(name))
1204                .expect("date finding");
1205            assert!(finding.verdicts.iter().any(|verdict| matches!(
1206                verdict,
1207                RefreshVerdict::InvalidMetadata { field, .. } if field == "last_verified"
1208            )));
1209        }
1210        assert!(report.check_needed());
1211    }
1212
1213    #[test]
1214    fn knowledge_refresh_reports_git_open_failure() {
1215        let tmp = tempfile::tempdir().unwrap();
1216        fs::write(tmp.path().join("AGENTS.md"), "rules\n").unwrap();
1217        vault(tmp.path());
1218        write_note(
1219            &tmp.path().join("docs/knowledge"),
1220            "architecture/no-git.md",
1221            "check-on-touch",
1222            &["AGENTS.md"],
1223            "Requires Git history.",
1224        );
1225
1226        let report = refresh_knowledge(tmp.path());
1227        let finding = report
1228            .findings
1229            .iter()
1230            .find(|finding| finding.rel_path.ends_with("no-git.md"))
1231            .expect("git-open finding");
1232        assert!(finding.verdicts.iter().any(|verdict| matches!(
1233            verdict,
1234            RefreshVerdict::ProbeFailed { target, .. } if target == "AGENTS.md"
1235        )));
1236        assert!(report.check_needed());
1237    }
1238
1239    #[test]
1240    fn knowledge_refresh_reports_git_log_failure() {
1241        let tmp = tempfile::tempdir().unwrap();
1242        git_init(tmp.path());
1243        fs::write(tmp.path().join("AGENTS.md"), "rules\n").unwrap();
1244        vault(tmp.path());
1245        write_note(
1246            &tmp.path().join("docs/knowledge"),
1247            "architecture/unborn.md",
1248            "check-on-touch",
1249            &["AGENTS.md"],
1250            "An unborn repository has no log to inspect.",
1251        );
1252
1253        let report = refresh_knowledge(tmp.path());
1254        let finding = report
1255            .findings
1256            .iter()
1257            .find(|finding| finding.rel_path.ends_with("unborn.md"))
1258            .expect("git-log finding");
1259        assert!(finding.verdicts.iter().any(|verdict| matches!(
1260            verdict,
1261            RefreshVerdict::ProbeFailed { target, .. } if target == "AGENTS.md"
1262        )));
1263        assert!(report.check_needed());
1264    }
1265
1266    #[test]
1267    fn knowledge_refresh_reports_malformed_note() {
1268        let tmp = tempfile::tempdir().unwrap();
1269        vault(tmp.path());
1270        fs::write(
1271            tmp.path().join("docs/knowledge/architecture/broken.md"),
1272            "# Missing frontmatter\n",
1273        )
1274        .unwrap();
1275
1276        let report = refresh_knowledge(tmp.path());
1277        let finding = report
1278            .findings
1279            .iter()
1280            .find(|finding| finding.rel_path.ends_with("broken.md"))
1281            .expect("malformed finding");
1282        assert!(finding.verdicts.iter().any(|verdict| matches!(
1283            verdict,
1284            RefreshVerdict::InvalidMetadata { field, .. } if field == "frontmatter"
1285        )));
1286        assert!(report.check_needed());
1287    }
1288
1289    #[test]
1290    fn knowledge_refresh_all_ok_is_clean() {
1291        let tmp = tempfile::tempdir().unwrap();
1292        git_init(tmp.path());
1293        fs::write(tmp.path().join("AGENTS.md"), "rules\n").unwrap();
1294        vault(tmp.path());
1295        write_note(
1296            &tmp.path().join("docs/knowledge"),
1297            "architecture/clean.md",
1298            "check-on-touch",
1299            &["AGENTS.md"],
1300            "No changes after verification.",
1301        );
1302        git_commit_all(tmp.path(), "seed");
1303
1304        let report = refresh_knowledge(tmp.path());
1305        assert!(!report.check_needed(), "{report:?}");
1306        assert!(report
1307            .findings
1308            .iter()
1309            .flat_map(|finding| &finding.verdicts)
1310            .all(|verdict| matches!(verdict, RefreshVerdict::Ok)));
1311    }
1312
1313    #[test]
1314    fn knowledge_refresh_checks_existing_path_with_spaces() {
1315        let tmp = tempfile::tempdir().unwrap();
1316        git_init(tmp.path());
1317        fs::write(tmp.path().join("path with spaces.md"), "evidence\n").unwrap();
1318        fs::write(tmp.path().join("AGENTS.md"), "rules\n").unwrap();
1319        vault(tmp.path());
1320        write_note(
1321            &tmp.path().join("docs/knowledge"),
1322            "architecture/spaces.md",
1323            "check-on-touch",
1324            &["path with spaces.md"],
1325            "Spaces do not imply a command.",
1326        );
1327        git_commit_all(tmp.path(), "seed");
1328
1329        let report = refresh_knowledge(tmp.path());
1330        let finding = report
1331            .findings
1332            .iter()
1333            .find(|finding| finding.rel_path.ends_with("spaces.md"))
1334            .expect("spaces finding");
1335        assert_eq!(finding.verdicts, [RefreshVerdict::Ok]);
1336    }
1337
1338    #[test]
1339    fn knowledge_refresh_missing_path_with_spaces_fails_closed() {
1340        let tmp = tempfile::tempdir().unwrap();
1341        git_init(tmp.path());
1342        fs::write(tmp.path().join("AGENTS.md"), "rules\n").unwrap();
1343        vault(tmp.path());
1344        write_note(
1345            &tmp.path().join("docs/knowledge"),
1346            "architecture/missing-spaces.md",
1347            "check-on-touch",
1348            &["deleted path with spaces.md"],
1349            "A deleted path stays a path.",
1350        );
1351        git_commit_all(tmp.path(), "seed");
1352
1353        let report = refresh_knowledge(tmp.path());
1354        let finding = report
1355            .findings
1356            .iter()
1357            .find(|finding| finding.rel_path.ends_with("missing-spaces.md"))
1358            .expect("spaces finding");
1359        assert!(report.check_needed(), "{report:?}");
1360        assert_eq!(
1361            finding.verdicts,
1362            [RefreshVerdict::PathMissing {
1363                path: "deleted path with spaces.md".to_string()
1364            }]
1365        );
1366    }
1367
1368    #[test]
1369    fn knowledge_refresh_rejects_outside_repo_citation() {
1370        let tmp = tempfile::tempdir().unwrap();
1371        let root = tmp.path().join("repo");
1372        fs::create_dir(&root).unwrap();
1373        fs::write(tmp.path().join("outside file.md"), "private\n").unwrap();
1374        vault(&root);
1375        write_note(
1376            &root.join("docs/knowledge"),
1377            "architecture/outside.md",
1378            "check-on-touch",
1379            &["../outside file.md"],
1380            "Must stay inside the repository.",
1381        );
1382
1383        let report = refresh_knowledge(&root);
1384        let finding = report
1385            .findings
1386            .iter()
1387            .find(|finding| finding.rel_path.ends_with("outside.md"))
1388            .expect("outside finding");
1389        assert!(finding.verdicts.iter().any(|verdict| matches!(
1390            verdict,
1391            RefreshVerdict::InvalidCitation { citation } if citation == "../outside file.md"
1392        )));
1393        assert!(report.check_needed());
1394    }
1395
1396    #[test]
1397    fn knowledge_refresh_already_stale_skips_broken_citations() {
1398        let tmp = tempfile::tempdir().unwrap();
1399        vault(tmp.path());
1400        write_note(
1401            &tmp.path().join("docs/knowledge"),
1402            "architecture/stale-broken.md",
1403            "stale",
1404            &["missing.rs"],
1405            "Already excluded from injection.",
1406        );
1407
1408        let report = refresh_knowledge(tmp.path());
1409        let finding = report
1410            .findings
1411            .iter()
1412            .find(|finding| finding.rel_path.ends_with("stale-broken.md"))
1413            .expect("stale finding");
1414        assert_eq!(finding.verdicts, [RefreshVerdict::AlreadyStale]);
1415    }
1416
1417    #[test]
1418    fn knowledge_refresh_same_day_commit_is_not_drift() {
1419        let tmp = tempfile::tempdir().unwrap();
1420        git_init(tmp.path());
1421        fs::write(tmp.path().join("AGENTS.md"), "rules\n").unwrap();
1422        vault(tmp.path());
1423        write_note(
1424            &tmp.path().join("docs/knowledge"),
1425            "architecture/same-day.md",
1426            "check-on-touch",
1427            &["AGENTS.md"],
1428            "Verified after the same-day change.",
1429        );
1430        replace_note_date(tmp.path(), "architecture/same-day.md", Some("2026-07-08"));
1431        git_commit_all_at(tmp.path(), "seed", "2026-07-08T12:00:00Z");
1432
1433        let report = refresh_knowledge(tmp.path());
1434        let finding = report
1435            .findings
1436            .iter()
1437            .find(|finding| finding.rel_path.ends_with("same-day.md"))
1438            .expect("same-day finding");
1439        assert_eq!(finding.verdicts, [RefreshVerdict::Ok]);
1440    }
1441}