Skip to main content

mati_core/analysis/
reparse.rs

1//! Incremental file reparse — used by `mati reparse`, `edit-hook`, and the MCP
2//! server socket handler.
3//!
4//! Steps:
5//! 1. Read file from disk. Missing → add FileDeleted staleness signal, return.
6//! 2. Detect language, construct WalkedFile, run parse_file().
7//! 3. Parse failure → log warning, return Ok (graceful degradation P9).
8//! 4. Fetch existing `file:<path>` record.
9//! 5. No record → create Layer 0 stub, persist, return.
10//! 6. Deserialize record.payload as FileRecord, compare structural fields.
11//! 7. Nothing changed → return early (no write).
12//! 8. Merge new analysis, preserve: purpose, gotcha_keys, decision_keys,
13//!    change_frequency, last_author, is_hotspot.
14//! 9. Apply staleness + cascade to linked gotchas (M-12-C).
15//! 10. Write back.
16//!
17//! **Not recomputed here:** Co-change cluster index. Clusters depend on
18//! CoChanges edges which only change when `mati init` re-mines git history
19//! after new commits. Incremental file edits do not affect cluster membership.
20
21use std::collections::HashSet;
22use std::time::{SystemTime, UNIX_EPOCH};
23
24use anyhow::Result;
25
26use crate::analysis::walker::{detect_language, WalkedFile};
27use crate::analysis::{parse_file, public_api_symbols, StaticFileAnalysis};
28use crate::health::staleness::{
29    apply_reparse_staleness, cascade_staleness_to_gotchas, ReparseDiff,
30};
31use crate::store::record::{
32    Category, ConfidenceScore, FileRecord, QualityScore, Record, RecordLifecycle, RecordSource,
33    RecordVersion, StalenessScore, StalenessSignal, StalenessTier,
34};
35use crate::store::Store;
36
37fn now_secs() -> u64 {
38    SystemTime::now()
39        .duration_since(UNIX_EPOCH)
40        .unwrap_or_default()
41        .as_secs()
42}
43
44/// Re-parse a single file and update its store record in place.
45///
46/// Called by:
47/// - `mati reparse <path>` CLI command
48/// - `mati edit-hook <path>` (via daemon socket or direct store)
49/// - MCP server socket `edit_hook` handler
50///
51/// Gracefully degrades on parse failure (P9). Never returns an error for
52/// missing files or parse issues — those are logged as warnings.
53pub async fn reparse_impl(
54    store: &Store,
55    repo_root: &std::path::Path,
56    rel_path: &str,
57) -> Result<()> {
58    let abs_path = repo_root.join(rel_path);
59    let file_key = format!("file:{rel_path}");
60    let now = now_secs();
61
62    // 1. Check if file exists on disk
63    if !abs_path.exists() {
64        // File deleted — add staleness signal if record exists
65        if let Some(mut record) = store.get(&file_key).await? {
66            record.staleness.value = 1.0;
67            record.staleness.tier = StalenessTier::Tombstone;
68            record.staleness.signals.push(StalenessSignal::FileDeleted);
69            record.staleness.computed_at = now;
70            record.updated_at = now;
71            record.version.logical_clock += 1;
72            record.version.wall_clock = now;
73            store.put(&file_key, &record).await?;
74        }
75        return Ok(());
76    }
77
78    // 2. Detect language and construct WalkedFile
79    let language = detect_language(&abs_path);
80    let size_bytes = std::fs::metadata(&abs_path).map(|m| m.len()).unwrap_or(0);
81
82    let walked = WalkedFile {
83        abs_path: abs_path.clone(),
84        rel_path: rel_path.to_string(),
85        language,
86        size_bytes,
87        mtime_secs: 0, // reparse always re-reads — mtime not needed
88    };
89
90    // 3. Parse file — graceful degradation on failure
91    let analysis = match parse_file(&walked) {
92        Ok(a) => a,
93        Err(e) => {
94            tracing::warn!("reparse: parse failed for {rel_path}: {e}");
95            return Ok(());
96        }
97    };
98
99    // 4. Fetch existing record
100    let existing = store.get(&file_key).await?;
101
102    // 5. No record → create Layer 0 stub
103    let Some(mut record) = existing else {
104        let file_record = build_file_record_from_analysis(rel_path, &analysis, &walked, now);
105        let new_record = Record {
106            key: file_key.clone(),
107            value: file_record.purpose.clone(),
108            payload: serde_json::to_value(&file_record).ok(),
109            category: Category::File,
110            priority: crate::store::record::Priority::Normal,
111            tags: vec![],
112            created_at: now,
113            updated_at: now,
114            ref_url: None,
115            staleness: StalenessScore::fresh(),
116            lifecycle: RecordLifecycle::Active,
117            version: RecordVersion {
118                device_id: crate::store::stable_device_id(),
119                logical_clock: 1,
120                wall_clock: now,
121            },
122            quality: QualityScore::layer0_default(),
123            access_count: 0,
124            last_accessed: 0,
125            source: RecordSource::StaticAnalysis,
126            confidence: ConfidenceScore::for_new_record(&RecordSource::StaticAnalysis),
127            gap_analysis_score: 0.0,
128        };
129        store.put(&file_key, &new_record).await?;
130        return Ok(());
131    };
132
133    // 6. Deserialize existing FileRecord from payload, compare
134    let old_fr: FileRecord = match record.payload_as::<FileRecord>() {
135        Some(fr) => fr,
136        None => {
137            // Missing or corrupt payload — rebuild from scratch, preserve key metadata
138            let file_record = build_file_record_from_analysis(rel_path, &analysis, &walked, now);
139            record.value = file_record.purpose.clone();
140            record.payload = serde_json::to_value(&file_record).ok();
141            record.updated_at = now;
142            record.version.logical_clock += 1;
143            record.version.wall_clock = now;
144            store.put(&file_key, &record).await?;
145            return Ok(());
146        }
147    };
148
149    // 7. Compute diff
150    let diff = compute_diff(&old_fr, &analysis);
151    if diff.is_empty() {
152        return Ok(());
153    }
154
155    // 8. Merge: update structural fields, preserve enrichment fields
156    let merged = FileRecord {
157        path: rel_path.to_string(),
158        purpose: old_fr.purpose,
159        entry_points: public_api_symbols(&analysis),
160        imports: analysis.imports.iter().map(|i| i.path.clone()).collect(),
161        gotcha_keys: old_fr.gotcha_keys.clone(),
162        decision_keys: old_fr.decision_keys,
163        todos: analysis.todos,
164        unsafe_count: analysis.unsafe_count,
165        unwrap_count: analysis.unwrap_count,
166        change_frequency: old_fr.change_frequency,
167        last_author: old_fr.last_author,
168        is_hotspot: old_fr.is_hotspot,
169        token_cost_estimate: (walked.size_bytes / 4).min(u32::MAX as u64) as u32,
170        last_modified_session: now,
171        content_hash: analysis.content_hash.clone(),
172        line_count: analysis.line_count,
173        blast_radius: old_fr.blast_radius,
174        propagated_staleness: old_fr.propagated_staleness,
175    };
176
177    record.value = merged.purpose.clone();
178    record.payload = serde_json::to_value(&merged).ok();
179
180    // 9. Apply staleness
181    let signals = apply_reparse_staleness(&mut record, &diff);
182
183    // 10. Bump version before cascade (gotchas may reference parent version)
184    record.updated_at = now;
185    record.version.logical_clock += 1;
186    record.version.wall_clock = now;
187
188    // 11. Cascade to linked gotchas
189    if !signals.is_empty() {
190        if let Err(e) = cascade_staleness_to_gotchas(store, &merged).await {
191            tracing::warn!("reparse: cascade to gotchas failed for {rel_path}: {e}");
192        }
193    }
194
195    // 12. Write back
196    store.put(&file_key, &record).await?;
197
198    Ok(())
199}
200
201/// Compute the reparse result without persisting. Returns the key and updated
202/// Record to write, or `None` if no write is needed (file missing, parse
203/// failure, or no structural changes).
204///
205/// The caller is responsible for committing the record (and any audit entry)
206/// in a single transaction via `transact_knowledge`.
207///
208/// Staleness cascade to linked gotchas is NOT included — it is a separate
209/// best-effort substep that the caller handles after committing the main write.
210pub async fn reparse_staged(
211    store: &Store,
212    repo_root: &std::path::Path,
213    rel_path: &str,
214) -> Result<Option<(String, Record)>> {
215    let abs_path = repo_root.join(rel_path);
216    let file_key = format!("file:{rel_path}");
217    let now = now_secs();
218
219    // 1. File deleted — update staleness.
220    if !abs_path.exists() {
221        if let Some(mut record) = store.get(&file_key).await? {
222            record.staleness.value = 1.0;
223            record.staleness.tier = StalenessTier::Tombstone;
224            record.staleness.signals.push(StalenessSignal::FileDeleted);
225            record.staleness.computed_at = now;
226            record.updated_at = now;
227            record.version.logical_clock += 1;
228            record.version.wall_clock = now;
229            return Ok(Some((file_key, record)));
230        }
231        return Ok(None);
232    }
233
234    // 2-3. Detect language, parse file.
235    let language = detect_language(&abs_path);
236    let size_bytes = std::fs::metadata(&abs_path).map(|m| m.len()).unwrap_or(0);
237    let walked = WalkedFile {
238        abs_path: abs_path.clone(),
239        rel_path: rel_path.to_string(),
240        language,
241        size_bytes,
242        mtime_secs: 0,
243    };
244    let analysis = match parse_file(&walked) {
245        Ok(a) => a,
246        Err(e) => {
247            tracing::warn!("reparse_staged: parse failed for {rel_path}: {e}");
248            return Ok(None);
249        }
250    };
251
252    // 4. Fetch existing record.
253    let existing = store.get(&file_key).await?;
254
255    // 5. No record → create Layer 0 stub.
256    let Some(mut record) = existing else {
257        let file_record = build_file_record_from_analysis(rel_path, &analysis, &walked, now);
258        let new_record = Record {
259            key: file_key.clone(),
260            value: file_record.purpose.clone(),
261            payload: serde_json::to_value(&file_record).ok(),
262            category: Category::File,
263            priority: crate::store::record::Priority::Normal,
264            tags: vec![],
265            created_at: now,
266            updated_at: now,
267            ref_url: None,
268            staleness: StalenessScore::fresh(),
269            lifecycle: RecordLifecycle::Active,
270            version: RecordVersion {
271                device_id: crate::store::stable_device_id(),
272                logical_clock: 1,
273                wall_clock: now,
274            },
275            quality: QualityScore::layer0_default(),
276            access_count: 0,
277            last_accessed: 0,
278            source: RecordSource::StaticAnalysis,
279            confidence: ConfidenceScore::for_new_record(&RecordSource::StaticAnalysis),
280            gap_analysis_score: 0.0,
281        };
282        return Ok(Some((file_key, new_record)));
283    };
284
285    // 6. Deserialize existing payload.
286    let old_fr: FileRecord = match record.payload_as::<FileRecord>() {
287        Some(fr) => fr,
288        None => {
289            let file_record = build_file_record_from_analysis(rel_path, &analysis, &walked, now);
290            record.value = file_record.purpose.clone();
291            record.payload = serde_json::to_value(&file_record).ok();
292            record.updated_at = now;
293            record.version.logical_clock += 1;
294            record.version.wall_clock = now;
295            return Ok(Some((file_key, record)));
296        }
297    };
298
299    // 7. Compute diff — no change means no write.
300    let diff = compute_diff(&old_fr, &analysis);
301    if diff.is_empty() {
302        return Ok(None);
303    }
304
305    // 8. Merge structural fields, preserve enrichment.
306    let merged = FileRecord {
307        path: rel_path.to_string(),
308        purpose: old_fr.purpose,
309        entry_points: public_api_symbols(&analysis),
310        imports: analysis.imports.iter().map(|i| i.path.clone()).collect(),
311        gotcha_keys: old_fr.gotcha_keys.clone(),
312        decision_keys: old_fr.decision_keys,
313        todos: analysis.todos,
314        unsafe_count: analysis.unsafe_count,
315        unwrap_count: analysis.unwrap_count,
316        change_frequency: old_fr.change_frequency,
317        last_author: old_fr.last_author,
318        is_hotspot: old_fr.is_hotspot,
319        token_cost_estimate: (walked.size_bytes / 4).min(u32::MAX as u64) as u32,
320        last_modified_session: now,
321        content_hash: analysis.content_hash.clone(),
322        line_count: analysis.line_count,
323        blast_radius: old_fr.blast_radius,
324        propagated_staleness: old_fr.propagated_staleness,
325    };
326
327    record.value = merged.purpose.clone();
328    record.payload = serde_json::to_value(&merged).ok();
329
330    // 9-10. Apply staleness, bump version.
331    let _signals = apply_reparse_staleness(&mut record, &diff);
332    record.updated_at = now;
333    record.version.logical_clock += 1;
334    record.version.wall_clock = now;
335
336    // NOTE: staleness cascade to linked gotchas is NOT done here.
337    // The caller handles it as a best-effort substep after committing.
338
339    Ok(Some((file_key, record)))
340}
341
342/// Build a fresh FileRecord from analysis output (no prior enrichment).
343fn build_file_record_from_analysis(
344    rel_path: &str,
345    analysis: &StaticFileAnalysis,
346    walked: &WalkedFile,
347    now: u64,
348) -> FileRecord {
349    FileRecord {
350        path: rel_path.to_string(),
351        purpose: String::new(),
352        entry_points: public_api_symbols(analysis),
353        imports: analysis.imports.iter().map(|i| i.path.clone()).collect(),
354        gotcha_keys: vec![],
355        decision_keys: vec![],
356        todos: analysis.todos.clone(),
357        unsafe_count: analysis.unsafe_count,
358        unwrap_count: analysis.unwrap_count,
359        change_frequency: 0,
360        last_author: None,
361        is_hotspot: false,
362        token_cost_estimate: (walked.size_bytes / 4).min(u32::MAX as u64) as u32,
363        last_modified_session: now,
364        content_hash: analysis.content_hash.clone(),
365        line_count: analysis.line_count,
366        blast_radius: None,
367        propagated_staleness: None,
368    }
369}
370
371/// Compute the structural diff between an old FileRecord and new analysis.
372pub fn compute_diff(old: &FileRecord, new: &StaticFileAnalysis) -> ReparseDiff {
373    let new_public_api = public_api_symbols(new);
374    let old_eps: HashSet<&str> = old.entry_points.iter().map(|s| s.as_str()).collect();
375    let new_eps: HashSet<&str> = new_public_api.iter().map(|s| s.as_str()).collect();
376
377    let entry_points_added: Vec<String> = new_eps
378        .difference(&old_eps)
379        .map(|s| s.to_string())
380        .collect();
381    let entry_points_removed: Vec<String> = old_eps
382        .difference(&new_eps)
383        .map(|s| s.to_string())
384        .collect();
385
386    let old_imports: HashSet<&str> = old.imports.iter().map(|s| s.as_str()).collect();
387    let new_imports: HashSet<&str> = new.imports.iter().map(|s| s.path.as_str()).collect();
388
389    let imports_added: Vec<String> = new_imports
390        .difference(&old_imports)
391        .map(|s| s.to_string())
392        .collect();
393    let imports_removed: Vec<String> = old_imports
394        .difference(&new_imports)
395        .map(|s| s.to_string())
396        .collect();
397
398    let todos_changed = old.todos.len() != new.todos.len()
399        || old
400            .todos
401            .iter()
402            .zip(new.todos.iter())
403            .any(|(a, b)| a.text != b.text || a.line != b.line);
404
405    let unsafe_delta = new.unsafe_count as i32 - old.unsafe_count as i32;
406    let unwrap_delta = new.unwrap_count as i32 - old.unwrap_count as i32;
407
408    ReparseDiff {
409        entry_points_added,
410        entry_points_removed,
411        imports_added,
412        imports_removed,
413        todos_changed,
414        unsafe_delta,
415        unwrap_delta,
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422    use crate::analysis::parser::{ImportKind, ImportStatement};
423    use crate::analysis::walker::Language;
424    use tempfile::TempDir;
425
426    fn make_old_file_record() -> FileRecord {
427        FileRecord {
428            path: "src/main.rs".into(),
429            purpose: "Main entry point".into(),
430            entry_points: vec!["main".into(), "old_fn".into()],
431            imports: vec!["std::io".into()],
432            gotcha_keys: vec!["gotcha:test".into()],
433            decision_keys: vec![],
434            todos: vec![],
435            unsafe_count: 0,
436            unwrap_count: 1,
437            change_frequency: 5,
438            last_author: Some("dev".into()),
439            is_hotspot: true,
440            token_cost_estimate: 100,
441            last_modified_session: 1_000_000,
442            content_hash: None,
443            line_count: 0,
444            blast_radius: None,
445            propagated_staleness: None,
446        }
447    }
448
449    fn make_new_analysis() -> StaticFileAnalysis {
450        StaticFileAnalysis {
451            path: "src/main.rs".into(),
452            language: Language::Rust,
453            entry_points: vec!["main".into(), "new_fn".into()],
454            exported_types: vec![],
455            imports: vec![
456                ImportStatement::new("std::io", ImportKind::Normal, 1),
457                ImportStatement::new("anyhow", ImportKind::Normal, 2),
458            ],
459            todos: vec![],
460            unsafe_count: 0,
461            unwrap_count: 0,
462            panic_count: 0,
463            branch_count: 0,
464            module_doc: None,
465            content_hash: None,
466            line_count: 0,
467        }
468    }
469
470    #[test]
471    fn compute_diff_detects_entry_point_changes() {
472        let old = make_old_file_record();
473        let new = make_new_analysis();
474        let diff = compute_diff(&old, &new);
475
476        assert!(diff.entry_points_added.contains(&"new_fn".to_string()));
477        assert!(diff.entry_points_removed.contains(&"old_fn".to_string()));
478    }
479
480    #[test]
481    fn compute_diff_detects_import_changes() {
482        let old = make_old_file_record();
483        let new = make_new_analysis();
484        let diff = compute_diff(&old, &new);
485
486        assert!(diff.imports_added.contains(&"anyhow".to_string()));
487        assert!(diff.imports_removed.is_empty());
488    }
489
490    #[test]
491    fn compute_diff_detects_unwrap_delta() {
492        let old = make_old_file_record();
493        let new = make_new_analysis();
494        let diff = compute_diff(&old, &new);
495        assert_eq!(diff.unwrap_delta, -1);
496    }
497
498    #[test]
499    fn compute_diff_empty_when_identical() {
500        let old = FileRecord {
501            path: "src/main.rs".into(),
502            purpose: "test".into(),
503            entry_points: vec!["main".into()],
504            imports: vec!["std::io".into()],
505            gotcha_keys: vec![],
506            decision_keys: vec![],
507            todos: vec![],
508            unsafe_count: 0,
509            unwrap_count: 0,
510            change_frequency: 0,
511            last_author: None,
512            is_hotspot: false,
513            token_cost_estimate: 0,
514            last_modified_session: 0,
515            content_hash: None,
516            line_count: 0,
517            blast_radius: None,
518            propagated_staleness: None,
519        };
520        let new = StaticFileAnalysis {
521            path: "src/main.rs".into(),
522            language: Language::Rust,
523            entry_points: vec!["main".into()],
524            exported_types: vec![],
525            imports: vec![ImportStatement::new("std::io", ImportKind::Normal, 1)],
526            todos: vec![],
527            unsafe_count: 0,
528            unwrap_count: 0,
529            panic_count: 0,
530            branch_count: 0,
531            module_doc: None,
532            content_hash: None,
533            line_count: 0,
534        };
535        let diff = compute_diff(&old, &new);
536        assert!(diff.is_empty());
537    }
538
539    #[tokio::test]
540    async fn reparse_creates_stub_for_unknown_file() {
541        let dir = TempDir::new().unwrap();
542        let repo = dir.path();
543        std::fs::write(repo.join("new_file.rs"), "pub fn hello() {}").unwrap();
544
545        let store = Store::open(repo).await.unwrap();
546        reparse_impl(&store, repo, "new_file.rs").await.unwrap();
547
548        let record = store.get("file:new_file.rs").await.unwrap();
549        assert!(record.is_some());
550        let r = record.unwrap();
551        assert_eq!(r.category, Category::File);
552
553        let fr: FileRecord = r.payload_as::<FileRecord>().unwrap();
554        assert!(fr.purpose.is_empty());
555        assert!(fr.entry_points.contains(&"hello".to_string()));
556
557        store.close().await.unwrap();
558    }
559
560    #[tokio::test]
561    async fn reparse_marks_deleted_file_as_tombstone() {
562        let dir = TempDir::new().unwrap();
563        let repo = dir.path();
564
565        let store = Store::open(repo).await.unwrap();
566
567        let fr = FileRecord {
568            path: "gone.rs".into(),
569            purpose: String::new(),
570            entry_points: vec![],
571            imports: vec![],
572            gotcha_keys: vec![],
573            decision_keys: vec![],
574            todos: vec![],
575            unsafe_count: 0,
576            unwrap_count: 0,
577            change_frequency: 0,
578            last_author: None,
579            is_hotspot: false,
580            token_cost_estimate: 0,
581            last_modified_session: 0,
582            content_hash: None,
583            line_count: 0,
584            blast_radius: None,
585            propagated_staleness: None,
586        };
587        let record = Record {
588            key: "file:gone.rs".into(),
589            value: serde_json::to_string(&fr).unwrap(),
590            category: Category::File,
591            priority: crate::store::record::Priority::Normal,
592            tags: vec![],
593            created_at: 1_000_000,
594            updated_at: 1_000_000,
595            ref_url: None,
596            staleness: StalenessScore::fresh(),
597            lifecycle: RecordLifecycle::Active,
598            version: RecordVersion {
599                device_id: uuid::Uuid::new_v4(),
600                logical_clock: 1,
601                wall_clock: 1_000_000,
602            },
603            quality: QualityScore::layer0_default(),
604            access_count: 0,
605            last_accessed: 0,
606            source: RecordSource::StaticAnalysis,
607            confidence: ConfidenceScore::for_new_record(&RecordSource::StaticAnalysis),
608            gap_analysis_score: 0.0,
609            payload: None,
610        };
611        store.put("file:gone.rs", &record).await.unwrap();
612
613        reparse_impl(&store, repo, "gone.rs").await.unwrap();
614
615        let updated = store.get("file:gone.rs").await.unwrap().unwrap();
616        assert_eq!(updated.staleness.tier, StalenessTier::Tombstone);
617        assert!(updated.staleness.value >= 1.0 - f32::EPSILON);
618
619        store.close().await.unwrap();
620    }
621
622    #[tokio::test]
623    async fn reparse_preserves_enrichment_fields_and_bumps_staleness() {
624        let dir = TempDir::new().unwrap();
625        let repo = dir.path();
626        std::fs::write(repo.join("lib.rs"), "pub fn new_fn() {}\npub fn kept() {}").unwrap();
627
628        let store = Store::open(repo).await.unwrap();
629
630        let fr = FileRecord {
631            path: "lib.rs".into(),
632            purpose: "Core library".into(),
633            entry_points: vec!["old_fn".into(), "kept".into()],
634            imports: vec![],
635            gotcha_keys: vec!["gotcha:important".into()],
636            decision_keys: vec!["decision:arch".into()],
637            todos: vec![],
638            unsafe_count: 0,
639            unwrap_count: 0,
640            change_frequency: 10,
641            last_author: Some("ioni".into()),
642            is_hotspot: true,
643            token_cost_estimate: 50,
644            last_modified_session: 1_000_000,
645            content_hash: None,
646            line_count: 0,
647            blast_radius: None,
648            propagated_staleness: None,
649        };
650        let record = Record {
651            key: "file:lib.rs".into(),
652            value: fr.purpose.clone(),
653            payload: serde_json::to_value(&fr).ok(),
654            category: Category::File,
655            priority: crate::store::record::Priority::Normal,
656            tags: vec![],
657            created_at: 1_000_000,
658            updated_at: 1_000_000,
659            ref_url: None,
660            staleness: StalenessScore::fresh(),
661            lifecycle: RecordLifecycle::Active,
662            version: RecordVersion {
663                device_id: uuid::Uuid::new_v4(),
664                logical_clock: 1,
665                wall_clock: 1_000_000,
666            },
667            quality: QualityScore::layer0_default(),
668            access_count: 3,
669            last_accessed: 1_000_000,
670            source: RecordSource::StaticAnalysis,
671            confidence: ConfidenceScore::for_new_record(&RecordSource::StaticAnalysis),
672            gap_analysis_score: 0.0,
673        };
674        store.put("file:lib.rs", &record).await.unwrap();
675
676        reparse_impl(&store, repo, "lib.rs").await.unwrap();
677
678        let updated = store.get("file:lib.rs").await.unwrap().unwrap();
679        let updated_fr: FileRecord = updated.payload_as::<FileRecord>().unwrap();
680
681        // Preserved enrichment
682        assert_eq!(updated_fr.purpose, "Core library");
683        assert_eq!(updated_fr.gotcha_keys, vec!["gotcha:important"]);
684        assert_eq!(updated_fr.decision_keys, vec!["decision:arch"]);
685        assert_eq!(updated_fr.change_frequency, 10);
686        assert_eq!(updated_fr.last_author.as_deref(), Some("ioni"));
687        assert!(updated_fr.is_hotspot);
688
689        // Updated structural fields
690        assert!(updated_fr.entry_points.contains(&"new_fn".to_string()));
691        assert!(updated_fr.entry_points.contains(&"kept".to_string()));
692        assert!(!updated_fr.entry_points.contains(&"old_fn".to_string()));
693        assert!(updated_fr.content_hash.is_some());
694        assert!(updated_fr.line_count > 0);
695
696        // Staleness should have bumped (entry point changes)
697        assert!(updated.staleness.value > 0.0);
698
699        store.close().await.unwrap();
700    }
701
702    #[tokio::test]
703    async fn reparse_noop_when_no_structural_changes() {
704        let dir = TempDir::new().unwrap();
705        let repo = dir.path();
706        std::fs::write(repo.join("stable.rs"), "pub fn run() {}").unwrap();
707
708        let store = Store::open(repo).await.unwrap();
709
710        let fr = FileRecord {
711            path: "stable.rs".into(),
712            purpose: "Stable module".into(),
713            entry_points: vec!["run".into()],
714            imports: vec![],
715            gotcha_keys: vec![],
716            decision_keys: vec![],
717            todos: vec![],
718            unsafe_count: 0,
719            unwrap_count: 0,
720            change_frequency: 0,
721            last_author: None,
722            is_hotspot: false,
723            token_cost_estimate: 50,
724            last_modified_session: 1_000_000,
725            content_hash: None,
726            line_count: 0,
727            blast_radius: None,
728            propagated_staleness: None,
729        };
730        let record = Record {
731            key: "file:stable.rs".into(),
732            value: fr.purpose.clone(),
733            payload: serde_json::to_value(&fr).ok(),
734            category: Category::File,
735            priority: crate::store::record::Priority::Normal,
736            tags: vec![],
737            created_at: 1_000_000,
738            updated_at: 1_000_000,
739            ref_url: None,
740            staleness: StalenessScore::fresh(),
741            lifecycle: RecordLifecycle::Active,
742            version: RecordVersion {
743                device_id: uuid::Uuid::new_v4(),
744                logical_clock: 1,
745                wall_clock: 1_000_000,
746            },
747            quality: QualityScore::layer0_default(),
748            access_count: 0,
749            last_accessed: 0,
750            source: RecordSource::StaticAnalysis,
751            confidence: ConfidenceScore::for_new_record(&RecordSource::StaticAnalysis),
752            gap_analysis_score: 0.0,
753        };
754        store.put("file:stable.rs", &record).await.unwrap();
755
756        reparse_impl(&store, repo, "stable.rs").await.unwrap();
757
758        // Version should NOT have changed (no write)
759        let after = store.get("file:stable.rs").await.unwrap().unwrap();
760        assert_eq!(after.version.logical_clock, 1);
761        assert_eq!(after.updated_at, 1_000_000);
762
763        store.close().await.unwrap();
764    }
765
766    #[tokio::test]
767    async fn reparse_preserves_exported_types_in_entry_points() {
768        let dir = TempDir::new().unwrap();
769        let repo = dir.path();
770        std::fs::write(repo.join("models.rs"), "pub struct Widget;\n").unwrap();
771
772        let store = Store::open(repo).await.unwrap();
773        reparse_impl(&store, repo, "models.rs").await.unwrap();
774
775        let record = store.get("file:models.rs").await.unwrap().unwrap();
776        let fr: FileRecord = record.payload_as::<FileRecord>().unwrap();
777        assert!(fr.entry_points.contains(&"Widget".to_string()));
778        assert!(fr.content_hash.is_some());
779        assert!(fr.line_count > 0);
780
781        store.close().await.unwrap();
782    }
783}