1use std::collections::BTreeMap;
10use std::io::Write as _;
11use std::path::Path;
12
13use serde::{Deserialize, Serialize};
14
15use crate::engine::Engine;
16use crate::index::{AsgIndexStore, IndexStore};
17use crate::ledger::{AsgLedgerStore, LedgerStore};
18use crate::paths::ASD_ROOT;
19use crate::schema::{AuthorKind, ConclusionClass, LedgerKind};
20use crate::sidecar_config::{ShardBy, SidecarConfig, package_key_for_filename};
21use crate::thinking::DEFAULT_CONFIDENCE_FLOOR;
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ExportRecord {
28 pub id: String,
29 pub kind: String,
30 pub qname: String,
31 pub file: String,
32 pub summary: String,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub body: Option<String>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub confidence: Option<f64>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub role: Option<String>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub command: Option<String>,
45 #[serde(default, skip_serializing_if = "Vec::is_empty")]
46 pub tags: Vec<String>,
47 #[serde(default, skip_serializing_if = "Vec::is_empty")]
48 pub evidence: Vec<serde_json::Value>,
49 #[serde(default, skip_serializing_if = "Vec::is_empty")]
50 pub supersedes: Vec<String>,
51 pub author: String,
52 pub created_at: String,
53}
54
55pub struct Gathered {
79 pub buckets: BTreeMap<&'static str, Vec<ExportRecord>>,
80 pub retired: std::collections::HashSet<String>,
84}
85
86pub fn gather_buckets(
88 engine: &Engine,
89) -> std::io::Result<BTreeMap<&'static str, Vec<ExportRecord>>> {
90 Ok(gather(engine)?.buckets)
91}
92
93pub fn gather(engine: &Engine) -> std::io::Result<Gathered> {
94 let index = AsgIndexStore::from_engine(engine);
95 let ref_name = engine.ref_name.clone();
96
97 let mut buckets: BTreeMap<&'static str, Vec<ExportRecord>> = BTreeMap::new();
98 for class in ConclusionClass::all() {
99 buckets.insert(class.filename_stem(), Vec::new());
100 }
101 let mut retired: std::collections::HashSet<String> = std::collections::HashSet::new();
105
106 let id_map = index.build_id_map(engine);
117 let ledger_prefix = format!("{}/ledger", ASD_ROOT);
118 let ledger_tree = engine
119 .repo
120 .get_tree(&ref_name, &ledger_prefix)
121 .unwrap_or(serde_json::Value::Null);
122 let by_symbol = match ledger_tree {
123 serde_json::Value::Object(map) => map,
124 _ => serde_json::Map::new(),
125 };
126
127 let mut symbol_ids: Vec<String> = by_symbol.keys().cloned().collect();
130 symbol_ids.sort();
131
132 for symbol_id in symbol_ids {
133 let sym = match id_map.get(&symbol_id) {
134 Some(s) => s,
135 None => continue,
139 };
140 let per_symbol = match by_symbol.get(&symbol_id) {
141 Some(serde_json::Value::Object(m)) => m,
142 _ => continue,
143 };
144 let mut entries: Vec<crate::schema::LedgerEntry> = per_symbol
148 .values()
149 .filter_map(|v| serde_json::from_value(v.clone()).ok())
150 .collect();
151 let superseded: std::collections::HashSet<String> = entries
152 .iter()
153 .flat_map(|e| e.supersedes.iter().cloned())
154 .collect();
155 for e in entries.iter().filter(|e| superseded.contains(&e.entry_id)) {
158 retired.insert(e.entry_id.clone());
159 }
160 entries.retain(|e| !superseded.contains(&e.entry_id));
161 for entry in entries {
162 if entry.kind == LedgerKind::Hypothesis
174 && entry.confidence.unwrap_or(0.0) < DEFAULT_CONFIDENCE_FLOOR
175 {
176 retired.insert(entry.entry_id.clone());
179 continue;
180 }
181 let class = entry.kind.conclusion_class();
182 let stem = class.filename_stem();
183 let record = ExportRecord {
184 id: entry.entry_id,
185 kind: entry.kind.as_str().to_string(),
186 qname: sym.qname.clone(),
187 file: sym.file.clone(),
188 summary: entry.summary,
189 body: entry.body,
190 confidence: entry.confidence,
191 role: entry.role,
192 command: entry.command,
193 tags: entry.tags,
194 evidence: entry
195 .evidence
196 .into_iter()
197 .map(|e| serde_json::to_value(e).unwrap_or(serde_json::Value::Null))
198 .collect(),
199 supersedes: entry.supersedes,
200 author: format!("{}:{}", author_kind_str(entry.author.kind), entry.author.id),
201 created_at: entry.created_at.to_rfc3339(),
202 };
203 if let Some(bucket) = buckets.get_mut(stem) {
204 bucket.push(record);
205 }
206 }
207 }
208
209 for bucket in buckets.values_mut() {
212 bucket.sort_by(|a, b| a.id.cmp(&b.id));
213 }
214 Ok(Gathered { buckets, retired })
215}
216
217pub fn merge_preserving(
238 fresh: &[ExportRecord],
239 path: &Path,
240 retired: &std::collections::HashSet<String>,
241) -> std::io::Result<Vec<ExportRecord>> {
242 let mut by_id: BTreeMap<String, ExportRecord> = BTreeMap::new();
243 for rec in read_jsonl(path).unwrap_or_default() {
244 if retired.contains(&rec.id) {
245 continue; }
247 by_id.insert(rec.id.clone(), rec);
248 }
249 for rec in fresh {
252 by_id.insert(rec.id.clone(), rec.clone());
253 }
254 let mut out: Vec<ExportRecord> = by_id.into_values().collect();
255 out.sort_by(|a, b| a.id.cmp(&b.id));
256 Ok(out)
257}
258
259pub fn write_jsonl(path: &Path, records: &[ExportRecord]) -> std::io::Result<u64> {
261 if let Some(parent) = path.parent() {
262 std::fs::create_dir_all(parent)?;
263 }
264 let mut buf = String::new();
265 for rec in records {
266 let line = serde_json::to_string(rec)
267 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
268 buf.push_str(&line);
269 buf.push('\n');
270 }
271 let mut f = std::fs::File::create(path)?;
272 f.write_all(buf.as_bytes())?;
273 Ok(buf.len() as u64)
274}
275
276pub fn read_jsonl(path: &Path) -> std::io::Result<Vec<ExportRecord>> {
281 let content = match std::fs::read_to_string(path) {
282 Ok(c) => c,
283 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
284 Err(e) => return Err(e),
285 };
286 let mut out = Vec::new();
287 for line in content.lines() {
288 let line = line.trim();
289 if line.is_empty() {
290 continue;
291 }
292 let rec: ExportRecord = serde_json::from_str(line).map_err(|e| {
293 std::io::Error::new(
294 std::io::ErrorKind::InvalidData,
295 format!(
296 "conclusions merge: unparseable line in {}: {e}",
297 path.display()
298 ),
299 )
300 })?;
301 out.push(rec);
302 }
303 Ok(out)
304}
305
306pub fn merge_jsonl(ours: &Path, theirs: &Path) -> std::io::Result<usize> {
324 let mut by_id: std::collections::BTreeMap<String, (String, ExportRecord)> =
325 std::collections::BTreeMap::new();
326 for path in [ours, theirs] {
327 for rec in read_jsonl(path)? {
328 let ser = serde_json::to_string(&rec)
329 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
330 match by_id.entry(rec.id.clone()) {
331 std::collections::btree_map::Entry::Vacant(v) => {
332 v.insert((ser, rec));
333 }
334 std::collections::btree_map::Entry::Occupied(mut o) => {
335 if ser > o.get().0 {
336 o.insert((ser, rec));
337 }
338 }
339 }
340 }
341 }
342 let records: Vec<ExportRecord> = by_id.into_values().map(|(_, rec)| rec).collect();
344 write_jsonl(ours, &records)?;
345 Ok(records.len())
346}
347
348pub fn export_all(
357 engine: &Engine,
358 out_dir: &Path,
359) -> std::io::Result<Vec<(&'static str, usize, u64)>> {
360 std::fs::create_dir_all(out_dir)?;
361 let project_root = out_dir
365 .parent() .and_then(|p| p.parent()) .unwrap_or(out_dir);
368 let cfg = SidecarConfig::load_from_project(project_root);
369
370 match cfg.shard_by {
371 ShardBy::Class => export_class_layout(engine, out_dir),
372 ShardBy::Package => export_package_layout(engine, out_dir),
373 }
374}
375
376fn export_class_layout(
378 engine: &Engine,
379 out_dir: &Path,
380) -> std::io::Result<Vec<(&'static str, usize, u64)>> {
381 let Gathered { buckets, retired } = gather(engine)?;
382 let mut out = Vec::with_capacity(6);
383 for class in ConclusionClass::all() {
384 let stem = class.filename_stem();
385 let path = out_dir.join(format!("{stem}.jsonl"));
386 let fresh = buckets.get(stem).cloned().unwrap_or_default();
387 let entries = merge_preserving(&fresh, &path, &retired)?;
390 let bytes = write_jsonl(&path, &entries)?;
391 out.push((stem, entries.len(), bytes));
392 }
393 Ok(out)
394}
395
396fn export_package_layout(
400 engine: &Engine,
401 out_dir: &Path,
402) -> std::io::Result<Vec<(&'static str, usize, u64)>> {
403 let Gathered { buckets, retired } = gather(engine)?;
404 let mut out = Vec::new();
405 for class in ConclusionClass::all() {
406 let stem = class.filename_stem();
407 let entries = buckets.get(stem).cloned().unwrap_or_default();
408 if entries.is_empty() {
409 continue;
410 }
411 let mut by_pkg: BTreeMap<String, Vec<ExportRecord>> = BTreeMap::new();
415 for rec in entries {
416 let pkg = package_dir(&rec.file);
417 by_pkg.entry(pkg).or_default().push(rec);
418 }
419 let class_dir = out_dir.join(stem);
420 std::fs::create_dir_all(&class_dir)?;
421 for (pkg, recs) in by_pkg {
422 let fname = format!("{}.jsonl", package_key_for_filename(&pkg));
423 let path = class_dir.join(fname);
424 let merged = merge_preserving(&recs, &path, &retired)?;
427 let bytes = write_jsonl(&path, &merged)?;
428 out.push((stem, merged.len(), bytes));
429 }
430 }
431 Ok(out)
432}
433
434fn package_dir(file: &str) -> String {
438 match file.rsplit_once('/') {
439 Some((d, _)) => d.to_string(),
440 None => String::new(),
441 }
442}
443
444#[derive(Debug, Clone)]
448pub struct ShardSize {
449 pub path: String,
452 pub bytes: u64,
453 pub over_per_shard: bool,
455}
456
457#[derive(Debug, Clone)]
460pub struct BudgetReport {
461 pub ok: bool,
462 pub total_bytes: u64,
463 pub total_budget: u64,
464 pub per_shard_budget: u64,
465 pub shards: Vec<ShardSize>,
466 pub over_total: bool,
468}
469
470pub fn check_budget(
475 out_dir: &Path,
476 budget: &crate::sidecar_config::BudgetConfig,
477) -> std::io::Result<BudgetReport> {
478 let mut shards: Vec<ShardSize> = Vec::new();
479 if out_dir.is_dir() {
480 collect_jsonl_sizes(out_dir, out_dir, &mut shards)?;
481 }
482 let mut total_bytes = 0u64;
483 for s in &mut shards {
484 s.over_per_shard = s.bytes > budget.per_shard_bytes;
485 total_bytes = total_bytes.saturating_add(s.bytes);
486 }
487 let over_total = total_bytes > budget.total_bytes;
488 let ok = !over_total && shards.iter().all(|s| !s.over_per_shard);
489 shards.sort_by(|a, b| a.path.cmp(&b.path));
491 Ok(BudgetReport {
492 ok,
493 total_bytes,
494 total_budget: budget.total_bytes,
495 per_shard_budget: budget.per_shard_bytes,
496 shards,
497 over_total,
498 })
499}
500
501fn collect_jsonl_sizes(base: &Path, dir: &Path, out: &mut Vec<ShardSize>) -> std::io::Result<()> {
502 for entry in std::fs::read_dir(dir)? {
503 let entry = entry?;
504 let path = entry.path();
505 if path.is_dir() {
506 collect_jsonl_sizes(base, &path, out)?;
507 } else if path.extension().and_then(|s| s.to_str()) == Some("jsonl") {
508 let bytes = path.metadata().map(|m| m.len()).unwrap_or(0);
509 let rel = path
510 .strip_prefix(base)
511 .unwrap_or(&path)
512 .to_string_lossy()
513 .replace('\\', "/");
514 out.push(ShardSize {
515 path: rel,
516 bytes,
517 over_per_shard: false,
518 });
519 }
520 }
521 Ok(())
522}
523
524fn author_kind_str(k: AuthorKind) -> &'static str {
525 match k {
526 AuthorKind::Agent => "agent",
527 AuthorKind::Human => "human",
528 }
529}
530
531use crate::schema::{Author, Evidence, LedgerEntry};
534use chrono::{DateTime, Utc};
535
536#[derive(Debug, Clone, serde::Serialize)]
538pub struct ImportFileResult {
539 pub class: &'static str,
540 pub file: String,
541 pub read: usize,
546 pub imported: usize,
547 pub skipped_up_to_date: usize,
551 pub skipped_unknown_qname: usize,
552 pub skipped_parse_error: usize,
553}
554
555pub fn import_all(
570 engine: &Engine,
571 in_dir: &Path,
572 agent_id: &str,
573) -> std::io::Result<Vec<ImportFileResult>> {
574 let mut out = Vec::new();
575 let mut stored = stored_entries(engine)?;
576 for class in ConclusionClass::all() {
577 let stem = class.filename_stem();
578 let class_file = in_dir.join(format!("{stem}.jsonl"));
580 if class_file.is_file() {
581 out.push(import_one(
582 engine,
583 &mut stored,
584 &class_file,
585 stem,
586 agent_id,
587 )?);
588 }
589 let pkg_dir = in_dir.join(stem);
592 if pkg_dir.is_dir() {
593 let mut shard_paths: Vec<std::path::PathBuf> = std::fs::read_dir(&pkg_dir)?
594 .filter_map(|e| e.ok().map(|e| e.path()))
595 .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("jsonl"))
596 .collect();
597 shard_paths.sort();
599 for path in shard_paths {
600 out.push(import_one(engine, &mut stored, &path, stem, agent_id)?);
601 }
602 }
603 if !class_file.is_file() && !pkg_dir.is_dir() {
606 out.push(ImportFileResult {
607 class: stem,
608 file: class_file.to_string_lossy().into_owned(),
609 read: 0,
610 imported: 0,
611 skipped_up_to_date: 0,
612 skipped_unknown_qname: 0,
613 skipped_parse_error: 0,
614 });
615 }
616 }
617 Ok(out)
618}
619
620fn import_one(
621 engine: &Engine,
622 stored: &mut std::collections::HashMap<String, LedgerEntry>,
623 path: &Path,
624 stem: &'static str,
625 agent_id: &str,
626) -> std::io::Result<ImportFileResult> {
627 let mut result = ImportFileResult {
628 class: stem,
629 file: path.display().to_string(),
630 read: 0,
631 imported: 0,
632 skipped_up_to_date: 0,
633 skipped_unknown_qname: 0,
634 skipped_parse_error: 0,
635 };
636 let text = match std::fs::read_to_string(path) {
637 Ok(t) => t,
638 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(result),
639 Err(e) => return Err(e),
640 };
641 let index = AsgIndexStore::from_engine(engine);
642 let ledger = AsgLedgerStore::from_engine(engine);
643 let ref_name = engine.ref_name.clone();
644
645 for line in text.lines() {
646 if line.trim().is_empty() {
647 continue;
648 }
649 result.read += 1;
650 let rec: ExportRecord = match serde_json::from_str(line) {
651 Ok(r) => r,
652 Err(_) => {
653 result.skipped_parse_error += 1;
654 continue;
655 }
656 };
657 let sym = match index.get_symbol_by_qname(&ref_name, &rec.qname) {
658 Ok(Some(s)) => s,
659 _ => {
660 result.skipped_unknown_qname += 1;
661 continue;
662 }
663 };
664 let entry = match record_to_entry(rec, &sym.symbol_id) {
665 Some(e) => e,
666 None => {
667 result.skipped_parse_error += 1;
668 continue;
669 }
670 };
671 if let Some(current) = stored.get(&entry.entry_id)
678 && revised_at(current) >= revised_at(&entry)
679 {
680 result.skipped_up_to_date += 1;
681 continue;
682 }
683 if ledger.append_entry(&ref_name, &entry, agent_id).is_ok() {
684 result.imported += 1;
685 stored.insert(entry.entry_id.clone(), entry);
686 } else {
687 result.skipped_parse_error += 1;
688 }
689 }
690 Ok(result)
691}
692
693pub(crate) fn revised_at(entry: &LedgerEntry) -> DateTime<Utc> {
699 entry
700 .tags
701 .iter()
702 .filter_map(|tag| tag.split_once("-at:"))
703 .filter_map(|(_, at)| DateTime::parse_from_rfc3339(at).ok())
704 .map(|at| at.with_timezone(&Utc))
705 .fold(entry.created_at, DateTime::max)
706}
707
708fn stored_entries(
714 engine: &Engine,
715) -> std::io::Result<std::collections::HashMap<String, LedgerEntry>> {
716 let mut stored: std::collections::HashMap<String, LedgerEntry> =
717 std::collections::HashMap::new();
718 let tree = match engine
719 .repo
720 .get_tree(&engine.ref_name, &crate::paths::ledger_root())
721 {
722 Ok(tree) => tree,
723 Err(agentstategraph::RepoError::Tree(agentstategraph::tree::TreeError::PathNotFound(
724 _,
725 ))) => return Ok(stored),
726 Err(e) => return Err(std::io::Error::other(format!("read the ledger: {e}"))),
729 };
730 let entries = tree
731 .as_object()
732 .into_iter()
733 .flat_map(|by_symbol| by_symbol.values())
734 .filter_map(|per_symbol| per_symbol.as_object())
735 .flat_map(|by_id| by_id.values())
736 .filter_map(|v| serde_json::from_value::<LedgerEntry>(v.clone()).ok());
737 for entry in entries {
738 match stored.get(&entry.entry_id) {
739 Some(kept) if revised_at(kept) >= revised_at(&entry) => {}
740 _ => {
741 stored.insert(entry.entry_id.clone(), entry);
742 }
743 }
744 }
745 Ok(stored)
746}
747
748fn record_to_entry(rec: ExportRecord, symbol_id: &str) -> Option<LedgerEntry> {
751 let kind = parse_kind(&rec.kind)?;
752 let author = parse_author(&rec.author)?;
753 let created_at: DateTime<Utc> = DateTime::parse_from_rfc3339(&rec.created_at)
754 .ok()?
755 .with_timezone(&Utc);
756 let evidence: Vec<Evidence> = rec
757 .evidence
758 .into_iter()
759 .filter_map(|v| serde_json::from_value(v).ok())
760 .collect();
761 Some(LedgerEntry {
762 entry_id: rec.id,
763 symbol_id: symbol_id.to_string(),
764 kind,
765 summary: rec.summary,
766 body: rec.body,
767 author,
768 confidence: rec.confidence,
769 evidence,
770 supersedes: rec.supersedes,
771 created_at,
772 tags: rec.tags,
773 matched_policy: None,
774 role: rec.role,
775 command: rec.command,
776 })
777}
778
779fn parse_kind(s: &str) -> Option<LedgerKind> {
791 serde_json::from_value(serde_json::Value::String(s.to_string())).ok()
792}
793
794fn parse_author(s: &str) -> Option<Author> {
795 let (kind_str, id) = s.split_once(':')?;
796 let kind = match kind_str {
797 "agent" => AuthorKind::Agent,
798 "human" => AuthorKind::Human,
799 _ => return None,
800 };
801 Some(Author {
802 kind,
803 id: id.to_string(),
804 })
805}
806
807#[cfg(test)]
808mod tests {
809 use super::*;
810 use crate::schema::SymbolKind;
811 use tempfile::tempdir;
812
813 #[test]
814 fn write_jsonl_is_byte_stable_across_runs() {
815 let tmp = tempdir().unwrap();
816 let path = tmp.path().join("decisions.jsonl");
817 let recs = vec![ExportRecord {
818 id: "led_1".into(),
819 kind: "decision".into(),
820 qname: "App.A".into(),
821 file: "a.rs".into(),
822 summary: "first".into(),
823 body: None,
824 confidence: None,
825 role: None,
826 command: None,
827 tags: vec![],
828 evidence: vec![],
829 supersedes: vec![],
830 author: "agent:c".into(),
831 created_at: "2026-05-19T20:00:00+00:00".into(),
832 }];
833 let n1 = write_jsonl(&path, &recs).unwrap();
834 let bytes1 = std::fs::read(&path).unwrap();
835 let n2 = write_jsonl(&path, &recs).unwrap();
836 let bytes2 = std::fs::read(&path).unwrap();
837 assert_eq!(n1, n2);
838 assert_eq!(bytes1, bytes2);
839 }
840
841 #[test]
842 fn optional_fields_are_skipped_in_serialization() {
843 let rec = ExportRecord {
844 id: "led_abc".into(),
845 kind: "decision".into(),
846 qname: "App.Foo.bar".into(),
847 file: "src/foo.rs".into(),
848 summary: "be careful".into(),
849 body: None,
850 confidence: None,
851 role: None,
852 command: None,
853 tags: vec![],
854 evidence: vec![],
855 supersedes: vec![],
856 author: "agent:claude".into(),
857 created_at: "2026-05-19T20:00:00+00:00".into(),
858 };
859 let line = serde_json::to_string(&rec).unwrap();
860 assert!(!line.contains("\"body\""));
861 assert!(!line.contains("\"role\""));
862 assert!(!line.contains("\"command\""));
863 assert!(!line.contains("\"tags\""));
864 assert!(line.contains("\"id\":\"led_abc\""));
865 }
866
867 #[test]
868 fn export_import_round_trips_an_entry() {
869 use crate::engine::Engine;
870 use crate::index::{AsgIndexStore, IndexStore};
871 use crate::ledger::{AsgLedgerStore, LedgerStore};
872 use crate::schema::{
873 Author, AuthorKind, LedgerEntry, LedgerKind, Position, Symbol, SymbolKind,
874 };
875
876 let engine = Engine::open_in_memory().unwrap();
877 let index = AsgIndexStore::from_engine(&engine);
878 let ledger = AsgLedgerStore::from_engine(&engine);
879
880 let sym = Symbol {
882 symbol_id: "sym_round_trip".into(),
883 symbol_fp: "fp_rt".into(),
884 qname: "App.Round.trip".into(),
885 language: "rust".into(),
886 kind: SymbolKind::Function,
887 file: "src/rt.rs".into(),
888 start: Position { line: 1, col: 0 },
889 end: Position { line: 10, col: 0 },
890 signature: None,
891 doc: None,
892 };
893 index.put_symbol(&engine.ref_name, &sym, "test").unwrap();
894
895 let mut entry = LedgerEntry::new(
897 &sym.symbol_id,
898 LedgerKind::FollowUp,
899 "diagnostics still need migration",
900 Author {
901 kind: AuthorKind::Agent,
902 id: "claude".into(),
903 },
904 );
905 entry.role = Some("diagnostic-test".into());
906 entry.command = Some("swift test --filter X".into());
907 entry.tags = vec!["ctx:plan:p".into()];
908 let original_id = entry.entry_id.clone();
909 ledger
910 .append_entry(&engine.ref_name, &entry, "test")
911 .unwrap();
912
913 let tmp = tempdir().unwrap();
915 export_all(&engine, tmp.path()).unwrap();
916
917 let engine2 = Engine::open_in_memory().unwrap();
918 let index2 = AsgIndexStore::from_engine(&engine2);
919 index2.put_symbol(&engine2.ref_name, &sym, "test").unwrap();
920 let results = import_all(&engine2, tmp.path(), "test").unwrap();
921
922 let total_imported: usize = results.iter().map(|r| r.imported).sum();
923 assert_eq!(total_imported, 1);
924
925 let ledger2 = AsgLedgerStore::from_engine(&engine2);
926 let entries = ledger2
927 .list_entries(&engine2.ref_name, &sym.symbol_id)
928 .unwrap();
929 assert_eq!(entries.len(), 1);
930 let imported = &entries[0];
931 assert_eq!(imported.entry_id, original_id);
932 assert_eq!(imported.kind, LedgerKind::FollowUp);
933 assert_eq!(imported.role.as_deref(), Some("diagnostic-test"));
934 assert_eq!(imported.command.as_deref(), Some("swift test --filter X"));
935 assert_eq!(imported.tags, vec!["ctx:plan:p".to_string()]);
936 }
937
938 #[test]
939 fn import_is_idempotent_when_run_twice() {
940 use crate::engine::Engine;
941 use crate::index::{AsgIndexStore, IndexStore};
942 use crate::ledger::{AsgLedgerStore, LedgerStore};
943 use crate::schema::{
944 Author, AuthorKind, LedgerEntry, LedgerKind, Position, Symbol, SymbolKind,
945 };
946
947 let engine = Engine::open_in_memory().unwrap();
948 let index = AsgIndexStore::from_engine(&engine);
949 let ledger = AsgLedgerStore::from_engine(&engine);
950 let sym = Symbol {
951 symbol_id: "sym_idem".into(),
952 symbol_fp: "fp_idem".into(),
953 qname: "App.Idem".into(),
954 language: "rust".into(),
955 kind: SymbolKind::Function,
956 file: "src/idem.rs".into(),
957 start: Position { line: 1, col: 0 },
958 end: Position { line: 2, col: 0 },
959 signature: None,
960 doc: None,
961 };
962 index.put_symbol(&engine.ref_name, &sym, "test").unwrap();
963 let entry = LedgerEntry::new(
964 &sym.symbol_id,
965 LedgerKind::Decision,
966 "x",
967 Author {
968 kind: AuthorKind::Agent,
969 id: "c".into(),
970 },
971 );
972 ledger
973 .append_entry(&engine.ref_name, &entry, "test")
974 .unwrap();
975
976 let tmp = tempdir().unwrap();
977 export_all(&engine, tmp.path()).unwrap();
978
979 let engine2 = Engine::open_in_memory().unwrap();
980 AsgIndexStore::from_engine(&engine2)
981 .put_symbol(&engine2.ref_name, &sym, "test")
982 .unwrap();
983 import_all(&engine2, tmp.path(), "test").unwrap();
984 import_all(&engine2, tmp.path(), "test").unwrap();
985
986 let entries = AsgLedgerStore::from_engine(&engine2)
987 .list_entries(&engine2.ref_name, &sym.symbol_id)
988 .unwrap();
989 assert_eq!(entries.len(), 1, "second import must not duplicate");
990 }
991
992 fn revision_symbol() -> crate::schema::Symbol {
995 crate::schema::Symbol {
996 symbol_id: "sym_rev".into(),
997 symbol_fp: "fp_rev".into(),
998 qname: "App.Rates.fetch".into(),
999 language: "python".into(),
1000 kind: SymbolKind::Function,
1001 file: "src/rates.py".into(),
1002 start: crate::schema::Position { line: 1, col: 0 },
1003 end: crate::schema::Position { line: 2, col: 0 },
1004 signature: None,
1005 doc: None,
1006 }
1007 }
1008
1009 fn store_with(entry: &LedgerEntry) -> Engine {
1011 let engine = Engine::open_in_memory().unwrap();
1012 AsgIndexStore::from_engine(&engine)
1013 .put_symbol(&engine.ref_name, &revision_symbol(), "test")
1014 .unwrap();
1015 AsgLedgerStore::from_engine(&engine)
1016 .append_entry(&engine.ref_name, entry, "test")
1017 .unwrap();
1018 engine
1019 }
1020
1021 fn decision() -> LedgerEntry {
1022 LedgerEntry::new(
1023 "sym_rev",
1024 LedgerKind::Decision,
1025 "retry rates once",
1026 Author {
1027 kind: AuthorKind::Agent,
1028 id: "c".into(),
1029 },
1030 )
1031 }
1032
1033 fn stored(engine: &Engine, entry_id: &str) -> LedgerEntry {
1034 AsgLedgerStore::from_engine(engine)
1035 .list_entries(&engine.ref_name, "sym_rev")
1036 .unwrap()
1037 .into_iter()
1038 .find(|e| e.entry_id == entry_id)
1039 .unwrap()
1040 }
1041
1042 fn approve(entry: &mut LedgerEntry, at: DateTime<Utc>) {
1043 entry.tags.push("approved".into());
1045 entry.tags.push(format!(
1046 "approved-at:{}",
1047 at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
1048 ));
1049 }
1050
1051 #[test]
1052 fn import_keeps_an_entry_approved_in_place_since_the_export() {
1053 let entry = decision();
1054 let engine = store_with(&entry);
1055 let tmp = tempdir().unwrap();
1056 export_all(&engine, tmp.path()).unwrap(); let mut approved = entry.clone();
1059 approve(&mut approved, entry.created_at + chrono::Duration::hours(1));
1060 AsgLedgerStore::from_engine(&engine)
1061 .append_entry(&engine.ref_name, &approved, "test")
1062 .unwrap();
1063
1064 let results = import_all(&engine, tmp.path(), "hook").unwrap();
1065 assert_eq!(
1066 results.iter().map(|r| r.skipped_up_to_date).sum::<usize>(),
1067 1
1068 );
1069 assert_eq!(results.iter().map(|r| r.imported).sum::<usize>(), 0);
1070 assert!(
1071 stored(&engine, &entry.entry_id)
1072 .tags
1073 .contains(&"approved".to_string()),
1074 "import reverted an in-place approval"
1075 );
1076 }
1077
1078 #[test]
1079 fn import_applies_a_teammates_in_place_approval() {
1080 let entry = decision();
1081 let mine = store_with(&entry);
1082
1083 let mut approved = entry.clone();
1084 approve(&mut approved, entry.created_at + chrono::Duration::hours(1));
1085 let theirs = store_with(&approved);
1086 let tmp = tempdir().unwrap();
1087 export_all(&theirs, tmp.path()).unwrap();
1088
1089 let results = import_all(&mine, tmp.path(), "hook").unwrap();
1090 assert_eq!(results.iter().map(|r| r.imported).sum::<usize>(), 1);
1091 assert!(
1092 stored(&mine, &entry.entry_id)
1093 .tags
1094 .contains(&"approved".to_string()),
1095 "a teammate's approval did not import"
1096 );
1097 }
1098
1099 #[test]
1100 fn import_applies_a_newer_rewrite_under_the_same_id() {
1101 let entry = decision();
1102 let mine = store_with(&entry);
1103
1104 let mut rewritten = entry.clone();
1106 rewritten.summary = "retry rates twice".into();
1107 rewritten.created_at = entry.created_at + chrono::Duration::hours(1);
1108 let theirs = store_with(&rewritten);
1109 let tmp = tempdir().unwrap();
1110 export_all(&theirs, tmp.path()).unwrap();
1111
1112 import_all(&mine, tmp.path(), "hook").unwrap();
1113 assert_eq!(stored(&mine, &entry.entry_id).summary, "retry rates twice");
1114
1115 let tmp_old = tempdir().unwrap();
1117 export_all(&store_with(&entry), tmp_old.path()).unwrap();
1118 import_all(&mine, tmp_old.path(), "hook").unwrap();
1119 assert_eq!(stored(&mine, &entry.entry_id).summary, "retry rates twice");
1120 }
1121
1122 fn engine_with_decisions_in_order(
1128 ids_and_summaries: &[(&str, &str)],
1129 ) -> (Engine, Vec<(String, String)>) {
1130 let engine = Engine::open_in_memory().unwrap();
1131 let sym = crate::schema::Symbol {
1132 symbol_id: "sym_order_test".into(),
1133 symbol_fp: "fp".into(),
1134 qname: "pkg.target".into(),
1135 language: "python".into(),
1136 kind: SymbolKind::Function,
1137 file: "src/target.py".into(),
1138 start: crate::schema::Position { line: 1, col: 0 },
1139 end: crate::schema::Position { line: 5, col: 0 },
1140 signature: None,
1141 doc: None,
1142 };
1143 AsgIndexStore::from_engine(&engine)
1144 .put_symbol(&engine.ref_name, &sym, "test")
1145 .unwrap();
1146 let ledger = AsgLedgerStore::from_engine(&engine);
1147 let mut inserted = Vec::new();
1148 for (id, summary) in ids_and_summaries {
1149 let mut entry = LedgerEntry::new(
1150 &sym.symbol_id,
1151 LedgerKind::Decision,
1152 *summary,
1153 Author {
1154 kind: AuthorKind::Agent,
1155 id: "t".into(),
1156 },
1157 );
1158 entry.entry_id = (*id).to_string();
1159 ledger.append_entry(&engine.ref_name, &entry, "t").unwrap();
1160 inserted.push(((*id).to_string(), (*summary).to_string()));
1161 }
1162 (engine, inserted)
1163 }
1164
1165 fn sha_of_file(p: &Path) -> [u8; 32] {
1166 let bytes = std::fs::read(p).expect("read jsonl");
1167 *blake3::hash(&bytes).as_bytes()
1168 }
1169
1170 #[test]
1171 fn export_is_byte_identical_across_repeated_runs() {
1172 let entries = [
1178 ("led_aaa", "first decision"),
1179 ("led_zzz", "third decision"),
1180 ("led_mmm", "second decision"),
1181 ];
1182 let (engine, _) = engine_with_decisions_in_order(&entries);
1183
1184 let tmp1 = tempfile::tempdir().unwrap();
1185 let tmp2 = tempfile::tempdir().unwrap();
1186 export_all(&engine, tmp1.path()).unwrap();
1187 export_all(&engine, tmp2.path()).unwrap();
1188
1189 assert_eq!(
1190 sha_of_file(&tmp1.path().join("decisions.jsonl")),
1191 sha_of_file(&tmp2.path().join("decisions.jsonl")),
1192 "two consecutive exports from the same engine must hash-equal"
1193 );
1194 }
1195
1196 #[test]
1197 fn export_byte_output_is_deterministic_under_id_sort() {
1198 let order_a = [
1205 ("led_aaa", "first decision"),
1206 ("led_zzz", "third decision"),
1207 ("led_mmm", "second decision"),
1208 ];
1209 let order_b = [
1210 ("led_zzz", "third decision"),
1211 ("led_mmm", "second decision"),
1212 ("led_aaa", "first decision"),
1213 ];
1214
1215 let (engine_a, _) = engine_with_decisions_in_order(&order_a);
1216 let (engine_b, _) = engine_with_decisions_in_order(&order_b);
1217
1218 let tmp_a = tempfile::tempdir().unwrap();
1219 let tmp_b = tempfile::tempdir().unwrap();
1220 export_all(&engine_a, tmp_a.path()).unwrap();
1221 export_all(&engine_b, tmp_b.path()).unwrap();
1222
1223 let extract = |path: &Path| -> Vec<(String, String)> {
1227 std::fs::read_to_string(path)
1228 .unwrap()
1229 .lines()
1230 .map(|l| {
1231 let v: serde_json::Value = serde_json::from_str(l).unwrap();
1232 (
1233 v["id"].as_str().unwrap_or("").to_string(),
1234 v["summary"].as_str().unwrap_or("").to_string(),
1235 )
1236 })
1237 .collect()
1238 };
1239 let seq_a = extract(&tmp_a.path().join("decisions.jsonl"));
1240 let seq_b = extract(&tmp_b.path().join("decisions.jsonl"));
1241 assert_eq!(
1242 seq_a, seq_b,
1243 "id-sort must yield identical (id, summary) sequence regardless of insertion order"
1244 );
1245 let ids: Vec<&str> = seq_a.iter().map(|(id, _)| id.as_str()).collect();
1247 assert_eq!(
1248 ids,
1249 vec!["led_aaa", "led_mmm", "led_zzz"],
1250 "entries must come out in id-sorted order"
1251 );
1252 }
1253
1254 fn engine_with_multi_package_decisions() -> Engine {
1258 let engine = Engine::open_in_memory().unwrap();
1259 let mk_sym = |sid: &str, qname: &str, file: &str| crate::schema::Symbol {
1260 symbol_id: sid.into(),
1261 symbol_fp: "fp".into(),
1262 qname: qname.into(),
1263 language: "python".into(),
1264 kind: SymbolKind::Function,
1265 file: file.into(),
1266 start: crate::schema::Position { line: 1, col: 0 },
1267 end: crate::schema::Position { line: 5, col: 0 },
1268 signature: None,
1269 doc: None,
1270 };
1271 let index = AsgIndexStore::from_engine(&engine);
1272 let sym_a = mk_sym("sym_a", "pkg.alpha.fn_a", "src/alpha/mod.py");
1273 let sym_b = mk_sym("sym_b", "pkg.beta.fn_b", "src/beta/mod.py");
1274 index.put_symbol(&engine.ref_name, &sym_a, "t").unwrap();
1275 index.put_symbol(&engine.ref_name, &sym_b, "t").unwrap();
1276
1277 let append = |sid: &str, eid: &str, summary: &str| {
1278 let mut entry = LedgerEntry::new(
1279 sid,
1280 LedgerKind::Decision,
1281 summary,
1282 Author {
1283 kind: AuthorKind::Agent,
1284 id: "t".into(),
1285 },
1286 );
1287 entry.entry_id = eid.into();
1288 AsgLedgerStore::from_engine(&engine)
1289 .append_entry(&engine.ref_name, &entry, "t")
1290 .unwrap();
1291 };
1292 append("sym_a", "led_a1", "alpha decision 1");
1293 append("sym_a", "led_a2", "alpha decision 2");
1294 append("sym_b", "led_b1", "beta decision 1");
1295 engine
1296 }
1297
1298 #[test]
1301 fn check_budget_reports_ok_when_well_under_limits() {
1302 use crate::sidecar_config::BudgetConfig;
1303 let tmp = tempdir().unwrap();
1304 std::fs::write(tmp.path().join("decisions.jsonl"), b"{}\n{}\n").unwrap();
1306 let budget = BudgetConfig {
1307 total_bytes: 1024,
1308 per_shard_bytes: 1024,
1309 };
1310 let r = check_budget(tmp.path(), &budget).unwrap();
1311 assert!(r.ok);
1312 assert!(!r.over_total);
1313 assert_eq!(r.shards.len(), 1);
1314 assert!(!r.shards[0].over_per_shard);
1315 }
1316
1317 #[test]
1318 fn check_budget_flags_over_total() {
1319 use crate::sidecar_config::BudgetConfig;
1320 let tmp = tempdir().unwrap();
1321 let blob = vec![b'x'; 600];
1322 std::fs::write(tmp.path().join("a.jsonl"), &blob).unwrap();
1323 std::fs::write(tmp.path().join("b.jsonl"), &blob).unwrap();
1324 let budget = BudgetConfig {
1325 total_bytes: 1000, per_shard_bytes: 1024,
1327 };
1328 let r = check_budget(tmp.path(), &budget).unwrap();
1329 assert!(!r.ok);
1330 assert!(r.over_total);
1331 assert!(r.shards.iter().all(|s| !s.over_per_shard));
1333 }
1334
1335 #[test]
1336 fn check_budget_flags_over_per_shard() {
1337 use crate::sidecar_config::BudgetConfig;
1338 let tmp = tempdir().unwrap();
1339 let big = vec![b'x'; 500];
1340 std::fs::write(tmp.path().join("big.jsonl"), &big).unwrap();
1341 let budget = BudgetConfig {
1342 total_bytes: 10_000,
1343 per_shard_bytes: 200, };
1345 let r = check_budget(tmp.path(), &budget).unwrap();
1346 assert!(!r.ok);
1347 assert!(!r.over_total);
1348 assert!(r.shards[0].over_per_shard);
1349 }
1350
1351 #[test]
1352 fn check_budget_walks_into_package_subdirs() {
1353 use crate::sidecar_config::BudgetConfig;
1358 let tmp = tempdir().unwrap();
1359 std::fs::create_dir_all(tmp.path().join("decisions")).unwrap();
1360 std::fs::write(tmp.path().join("decisions/crates--core.jsonl"), b"row\n").unwrap();
1361 let r = check_budget(
1362 tmp.path(),
1363 &BudgetConfig {
1364 total_bytes: 1024,
1365 per_shard_bytes: 1024,
1366 },
1367 )
1368 .unwrap();
1369 assert_eq!(r.shards.len(), 1);
1370 assert!(
1371 r.shards[0].path.contains("decisions/crates--core.jsonl")
1372 || r.shards[0].path.contains("decisions\\crates--core.jsonl"),
1373 "shard path must include the subdir; got {:?}",
1374 r.shards[0].path
1375 );
1376 }
1377
1378 #[test]
1379 fn check_budget_handles_missing_directory_gracefully() {
1380 use crate::sidecar_config::BudgetConfig;
1381 let tmp = tempdir().unwrap();
1382 let r = check_budget(&tmp.path().join("conclusions"), &BudgetConfig::default()).unwrap();
1384 assert!(r.ok, "empty/missing dir is trivially under budget");
1385 assert_eq!(r.total_bytes, 0);
1386 assert!(r.shards.is_empty());
1387 }
1388
1389 #[test]
1390 fn package_layout_writes_per_package_shards() {
1391 let tmp = tempdir().unwrap();
1395 let project_root = tmp.path();
1396 std::fs::create_dir_all(project_root.join(".asd")).unwrap();
1397 std::fs::write(
1398 project_root.join(".asd/config.toml"),
1399 "[sidecar]\nshard_by = \"package\"\n",
1400 )
1401 .unwrap();
1402
1403 let engine = engine_with_multi_package_decisions();
1404 let conclusions_dir = project_root.join(".asd/conclusions");
1405 export_all(&engine, &conclusions_dir).unwrap();
1406
1407 assert!(
1409 !conclusions_dir.join("decisions.jsonl").is_file(),
1410 "package layout must not write a flat decisions.jsonl"
1411 );
1412 let alpha = conclusions_dir.join("decisions/src--alpha.jsonl");
1414 let beta = conclusions_dir.join("decisions/src--beta.jsonl");
1415 assert!(alpha.is_file(), "expected per-package shard at {alpha:?}");
1416 assert!(beta.is_file(), "expected per-package shard at {beta:?}");
1417
1418 let alpha_body = std::fs::read_to_string(&alpha).unwrap();
1419 assert!(alpha_body.contains("led_a1") && alpha_body.contains("led_a2"));
1420 assert!(
1421 !alpha_body.contains("led_b1"),
1422 "beta entry must not leak into alpha shard; got:\n{alpha_body}"
1423 );
1424 }
1425
1426 #[test]
1427 fn class_layout_is_default_without_config() {
1428 let tmp = tempdir().unwrap();
1431 let project_root = tmp.path();
1432 let engine = engine_with_multi_package_decisions();
1435 let conclusions_dir = project_root.join(".asd/conclusions");
1436 export_all(&engine, &conclusions_dir).unwrap();
1437
1438 let flat = conclusions_dir.join("decisions.jsonl");
1439 assert!(
1440 flat.is_file(),
1441 "default layout must write flat decisions.jsonl"
1442 );
1443 let body = std::fs::read_to_string(&flat).unwrap();
1444 for id in ["led_a1", "led_a2", "led_b1"] {
1445 assert!(
1446 body.contains(id),
1447 "flat file must contain {id}; got:\n{body}"
1448 );
1449 }
1450 assert!(
1452 !conclusions_dir.join("decisions").is_dir(),
1453 "default layout must not create per-class subdirectory"
1454 );
1455 }
1456
1457 #[test]
1458 fn import_reads_package_layout_transparently() {
1459 let tmp = tempdir().unwrap();
1463 let project_root = tmp.path();
1464 std::fs::create_dir_all(project_root.join(".asd")).unwrap();
1465 std::fs::write(
1466 project_root.join(".asd/config.toml"),
1467 "[sidecar]\nshard_by = \"package\"\n",
1468 )
1469 .unwrap();
1470
1471 let engine = engine_with_multi_package_decisions();
1472 let conclusions_dir = project_root.join(".asd/conclusions");
1473 export_all(&engine, &conclusions_dir).unwrap();
1474
1475 let engine2 = Engine::open_in_memory().unwrap();
1478 let index2 = AsgIndexStore::from_engine(&engine2);
1479 for (sid, qn, file) in [
1480 ("sym_a", "pkg.alpha.fn_a", "src/alpha/mod.py"),
1481 ("sym_b", "pkg.beta.fn_b", "src/beta/mod.py"),
1482 ] {
1483 let sym = crate::schema::Symbol {
1484 symbol_id: sid.into(),
1485 symbol_fp: "fp".into(),
1486 qname: qn.into(),
1487 language: "python".into(),
1488 kind: SymbolKind::Function,
1489 file: file.into(),
1490 start: crate::schema::Position { line: 1, col: 0 },
1491 end: crate::schema::Position { line: 5, col: 0 },
1492 signature: None,
1493 doc: None,
1494 };
1495 index2.put_symbol(&engine2.ref_name, &sym, "t").unwrap();
1496 }
1497 let results = import_all(&engine2, &conclusions_dir, "t").unwrap();
1498 let total: usize = results.iter().map(|r| r.imported).sum();
1499 assert_eq!(
1500 total, 3,
1501 "all 3 entries (2 alpha + 1 beta) must import; got results = {results:?}"
1502 );
1503 let ledger2 = AsgLedgerStore::from_engine(&engine2);
1505 let a = ledger2.list_entries(&engine2.ref_name, "sym_a").unwrap();
1506 let b = ledger2.list_entries(&engine2.ref_name, "sym_b").unwrap();
1507 assert_eq!(a.len(), 2, "sym_a must have 2 imported entries");
1508 assert_eq!(b.len(), 1, "sym_b must have 1 imported entry");
1509 }
1510
1511 fn append_thinking_entry(
1515 engine: &Engine,
1516 kind: LedgerKind,
1517 id: &str,
1518 summary: &str,
1519 confidence: Option<f64>,
1520 ) {
1521 let mut entry = LedgerEntry::new(
1522 "sym_order_test",
1523 kind,
1524 summary,
1525 Author {
1526 kind: AuthorKind::Agent,
1527 id: "t".into(),
1528 },
1529 );
1530 entry.entry_id = id.to_string();
1531 entry.confidence = confidence;
1532 AsgLedgerStore::from_engine(engine)
1533 .append_entry(&engine.ref_name, &entry, "t")
1534 .unwrap();
1535 }
1536
1537 #[test]
1538 fn export_drops_low_confidence_hypotheses() {
1539 let (engine, _) = engine_with_decisions_in_order(&[]);
1543 append_thinking_entry(
1545 &engine,
1546 LedgerKind::Hypothesis,
1547 "led_strong",
1548 "well-supported hypothesis",
1549 Some(0.7),
1550 );
1551 append_thinking_entry(
1553 &engine,
1554 LedgerKind::Hypothesis,
1555 "led_weak",
1556 "speculative guess",
1557 Some(0.1),
1558 );
1559 append_thinking_entry(
1561 &engine,
1562 LedgerKind::Hypothesis,
1563 "led_unset",
1564 "no confidence assigned",
1565 None,
1566 );
1567
1568 let tmp = tempfile::tempdir().unwrap();
1569 export_all(&engine, tmp.path()).unwrap();
1570 let body = std::fs::read_to_string(tmp.path().join("thinking.jsonl")).unwrap();
1571
1572 assert!(
1573 body.contains("led_strong"),
1574 "high-confidence hypothesis must ship; got:\n{body}"
1575 );
1576 assert!(
1577 !body.contains("led_weak"),
1578 "low-confidence hypothesis must NOT ship; got:\n{body}"
1579 );
1580 assert!(
1581 !body.contains("led_unset"),
1582 "hypothesis with no confidence must NOT ship; got:\n{body}"
1583 );
1584 }
1585
1586 #[test]
1587 fn confidence_floor_does_not_affect_non_hypothesis_kinds() {
1588 let (engine, _) = engine_with_decisions_in_order(&[]);
1594 append_thinking_entry(
1595 &engine,
1596 LedgerKind::MentalModel,
1597 "led_mm_low",
1598 "mental model with low conf marker",
1599 Some(0.1),
1600 );
1601 append_thinking_entry(
1602 &engine,
1603 LedgerKind::OpenQuestion,
1604 "led_oq_unset",
1605 "what does this mean?",
1606 None,
1607 );
1608 append_thinking_entry(
1609 &engine,
1610 LedgerKind::FailedAttempt,
1611 "led_fa_low",
1612 "tried X, failed because Y",
1613 Some(0.1),
1614 );
1615
1616 let tmp = tempfile::tempdir().unwrap();
1617 export_all(&engine, tmp.path()).unwrap();
1618 let body = std::fs::read_to_string(tmp.path().join("thinking.jsonl")).unwrap();
1619
1620 for must_appear in ["led_mm_low", "led_oq_unset", "led_fa_low"] {
1621 assert!(
1622 body.contains(must_appear),
1623 "non-Hypothesis kind {must_appear} must ship regardless of confidence; got:\n{body}"
1624 );
1625 }
1626 }
1627
1628 #[test]
1629 fn confidence_floor_does_not_affect_decisions() {
1630 let (engine, _) = engine_with_decisions_in_order(&[]);
1634 append_thinking_entry(
1635 &engine,
1636 LedgerKind::Decision,
1637 "led_dec_low",
1638 "decided X with low certainty",
1639 Some(0.1),
1640 );
1641
1642 let tmp = tempfile::tempdir().unwrap();
1643 export_all(&engine, tmp.path()).unwrap();
1644 let body = std::fs::read_to_string(tmp.path().join("decisions.jsonl")).unwrap();
1645 assert!(
1646 body.contains("led_dec_low"),
1647 "low-confidence Decision must still ship; got:\n{body}"
1648 );
1649 }
1650
1651 #[test]
1652 fn exported_entries_are_self_describing() {
1653 let entries = [("led_target_1", "first decision about pkg.target")];
1659 let (engine, _) = engine_with_decisions_in_order(&entries);
1660 let tmp = tempfile::tempdir().unwrap();
1661 export_all(&engine, tmp.path()).unwrap();
1662
1663 let body = std::fs::read_to_string(tmp.path().join("decisions.jsonl")).unwrap();
1664 assert!(
1667 body.contains("pkg.target"),
1668 "qname must appear in serialized line for grep-by-qname; got: {body}"
1669 );
1670
1671 let v: serde_json::Value =
1674 serde_json::from_str(body.lines().next().expect("one line")).unwrap();
1675 for field in ["id", "kind", "qname", "summary"] {
1676 let s = v[field].as_str().unwrap_or("");
1677 assert!(
1678 !s.is_empty(),
1679 "field `{field}` must be present and non-empty for self-describing entries; got {v}"
1680 );
1681 }
1682 assert!(
1685 v.get("symbol_id").is_none(),
1686 "ExportRecord must not leak opaque symbol_id; got {v}"
1687 );
1688 }
1689
1690 #[test]
1691 fn export_sorts_by_id_not_by_created_at() {
1692 let entries = [
1697 ("led_zzz", "zzz summary inserted first"),
1698 ("led_aaa", "aaa summary inserted second"),
1699 ];
1700 let (engine, _) = engine_with_decisions_in_order(&entries);
1701 let tmp = tempfile::tempdir().unwrap();
1702 export_all(&engine, tmp.path()).unwrap();
1703
1704 let body = std::fs::read_to_string(tmp.path().join("decisions.jsonl")).unwrap();
1705 let lines: Vec<&str> = body.lines().collect();
1706 assert_eq!(lines.len(), 2);
1707 assert!(
1710 lines[0].contains("led_aaa"),
1711 "id-sort: led_aaa must precede led_zzz; got first line = {}",
1712 lines[0]
1713 );
1714 assert!(
1715 lines[1].contains("led_zzz"),
1716 "id-sort: led_zzz must follow led_aaa; got second line = {}",
1717 lines[1]
1718 );
1719 }
1720
1721 #[test]
1724 fn parse_kind_accepts_every_ledger_kind_string() {
1725 let all = [
1729 LedgerKind::Decision,
1730 LedgerKind::Assumption,
1731 LedgerKind::Constraint,
1732 LedgerKind::Rationale,
1733 LedgerKind::Hazard,
1734 LedgerKind::Tradeoff,
1735 LedgerKind::Invariant,
1736 LedgerKind::Ownership,
1737 LedgerKind::Proof,
1738 LedgerKind::ValidationScenario,
1739 LedgerKind::KnownBug,
1740 LedgerKind::Concept,
1741 LedgerKind::Mapping,
1742 LedgerKind::FollowUp,
1743 LedgerKind::Hypothesis,
1744 LedgerKind::MentalModel,
1745 LedgerKind::FailedAttempt,
1746 LedgerKind::OpenQuestion,
1747 ];
1748 for kind in all {
1749 assert_eq!(
1750 parse_kind(kind.as_str()),
1751 Some(kind),
1752 "parse_kind must accept `{}`",
1753 kind.as_str()
1754 );
1755 }
1756 assert_eq!(parse_kind("no_such_kind"), None);
1757 }
1758
1759 #[test]
1760 fn export_wipe_import_round_trips_all_classes_losslessly() {
1761 use crate::schema::{Author, AuthorKind, LedgerEntry, LedgerKind};
1768
1769 let (engine, _) = engine_with_decisions_in_order(&[]);
1770 let ledger = AsgLedgerStore::from_engine(&engine);
1771
1772 let mk = |kind: LedgerKind, id: &str, summary: &str| -> LedgerEntry {
1773 let mut e = LedgerEntry::new(
1774 "sym_order_test",
1775 kind,
1776 summary,
1777 Author {
1778 kind: AuthorKind::Agent,
1779 id: "t".into(),
1780 },
1781 );
1782 e.entry_id = id.to_string();
1783 e
1784 };
1785
1786 let mut originals: Vec<LedgerEntry> = Vec::new();
1787
1788 let mut hyp = mk(
1790 LedgerKind::Hypothesis,
1791 "led_rt_hyp",
1792 "suspect the cache is stale",
1793 );
1794 hyp.confidence = Some(0.85);
1795 hyp.body = Some("saw stale reads under load".into());
1796 hyp.tags = vec!["source:asd-think".into()];
1797 originals.push(hyp);
1798
1799 let mut mm = mk(
1800 LedgerKind::MentalModel,
1801 "led_rt_mm",
1802 "pipeline flows input -> mix -> out",
1803 );
1804 mm.body = Some(r#"{"symbols":["pkg.target","pkg.other"],"name":"audio-pipeline"}"#.into());
1805 originals.push(mm);
1806
1807 let mut fa = mk(
1808 LedgerKind::FailedAttempt,
1809 "led_rt_fa",
1810 "tried batching, failed on ordering",
1811 );
1812 fa.body = Some(r#"{"tried":"batch writes","because":"reorders events"}"#.into());
1813 fa.confidence = Some(0.4);
1814 originals.push(fa);
1815
1816 let mut oq = mk(
1817 LedgerKind::OpenQuestion,
1818 "led_rt_oq",
1819 "what does constant 4096 mean?",
1820 );
1821 oq.body = Some("partial finding: it's a page size somewhere".into());
1822 originals.push(oq);
1823
1824 let mut dec = mk(LedgerKind::Decision, "led_rt_dec", "chose sqlite");
1827 dec.confidence = Some(0.9);
1828 originals.push(dec);
1829 let mut own = mk(LedgerKind::Ownership, "led_rt_own", "owned by core team");
1830 own.role = Some("fixture-path".into());
1831 originals.push(own);
1832 let mut map = mk(LedgerKind::Mapping, "led_rt_map", "coverage moved");
1833 map.body = Some(r#"{"from_qname":"a","to_qname":"b"}"#.into());
1834 originals.push(map);
1835 originals.push(mk(LedgerKind::Hazard, "led_rt_haz", "races under load"));
1836 let mut val = mk(
1837 LedgerKind::ValidationScenario,
1838 "led_rt_val",
1839 "replay the file",
1840 );
1841 val.command = Some("cargo test -p core".into());
1842 originals.push(val);
1843 let mut fu = mk(LedgerKind::FollowUp, "led_rt_fu", "migrate diagnostics");
1844 fu.supersedes = vec!["led_rt_old".into()];
1845 originals.push(fu);
1846
1847 for e in &originals {
1848 ledger.append_entry(&engine.ref_name, e, "t").unwrap();
1849 }
1850
1851 let tmp = tempfile::tempdir().unwrap();
1852 export_all(&engine, tmp.path()).unwrap();
1853
1854 let engine2 = Engine::open_in_memory().unwrap();
1856 let sym = crate::schema::Symbol {
1857 symbol_id: "sym_order_test".into(),
1858 symbol_fp: "fp".into(),
1859 qname: "pkg.target".into(),
1860 language: "python".into(),
1861 kind: SymbolKind::Function,
1862 file: "src/target.py".into(),
1863 start: crate::schema::Position { line: 1, col: 0 },
1864 end: crate::schema::Position { line: 5, col: 0 },
1865 signature: None,
1866 doc: None,
1867 };
1868 AsgIndexStore::from_engine(&engine2)
1869 .put_symbol(&engine2.ref_name, &sym, "t")
1870 .unwrap();
1871
1872 let results = import_all(&engine2, tmp.path(), "t").unwrap();
1873
1874 for r in &results {
1877 assert_eq!(
1878 r.read, r.imported,
1879 "class `{}` must import everything it reads; got {r:?}",
1880 r.class
1881 );
1882 assert_eq!(r.skipped_parse_error, 0, "class `{}`: {r:?}", r.class);
1883 assert_eq!(r.skipped_unknown_qname, 0, "class `{}`: {r:?}", r.class);
1884 }
1885 let thinking = results
1886 .iter()
1887 .find(|r| r.class == "thinking")
1888 .expect("thinking class must be reported");
1889 assert_eq!(
1890 thinking.imported, 4,
1891 "all four thinking kinds must import; got {thinking:?}"
1892 );
1893 let total: usize = results.iter().map(|r| r.imported).sum();
1894 assert_eq!(total, originals.len());
1895
1896 let imported = AsgLedgerStore::from_engine(&engine2)
1901 .list_entries(&engine2.ref_name, "sym_order_test")
1902 .unwrap();
1903 assert_eq!(imported.len(), originals.len());
1904 let by_id: std::collections::BTreeMap<String, &LedgerEntry> =
1905 imported.iter().map(|e| (e.entry_id.clone(), e)).collect();
1906 for orig in &originals {
1907 let got = by_id
1908 .get(&orig.entry_id)
1909 .unwrap_or_else(|| panic!("entry {} missing after import", orig.entry_id));
1910 assert_eq!(
1911 serde_json::to_value(orig).unwrap(),
1912 serde_json::to_value(got).unwrap(),
1913 "entry {} must round-trip losslessly",
1914 orig.entry_id
1915 );
1916 }
1917 }
1918
1919 #[test]
1920 fn import_counts_thinking_and_unknown_kinds_as_reads() {
1921 let (engine, _) = engine_with_decisions_in_order(&[]);
1926 let tmp = tempfile::tempdir().unwrap();
1927 std::fs::write(
1928 tmp.path().join("thinking.jsonl"),
1929 concat!(
1930 r#"{"id":"led_ok","kind":"open_question","qname":"pkg.target","file":"src/target.py","summary":"q?","author":"agent:t","created_at":"2026-05-19T20:00:00+00:00"}"#,
1931 "\n",
1932 r#"{"id":"led_bad","kind":"from_the_future","qname":"pkg.target","file":"src/target.py","summary":"?","author":"agent:t","created_at":"2026-05-19T20:00:00+00:00"}"#,
1933 "\n",
1934 ),
1935 )
1936 .unwrap();
1937
1938 let results = import_all(&engine, tmp.path(), "t").unwrap();
1939 let thinking = results
1940 .iter()
1941 .find(|r| r.class == "thinking")
1942 .expect("thinking reported");
1943 assert_eq!(thinking.read, 2);
1944 assert_eq!(thinking.imported, 1);
1945 assert_eq!(thinking.skipped_parse_error, 1);
1946 assert_eq!(
1947 thinking.read,
1948 thinking.imported + thinking.skipped_unknown_qname + thinking.skipped_parse_error,
1949 "read must equal imported + skips"
1950 );
1951 }
1952
1953 #[test]
1954 fn confidence_survives_export_import_for_thinking_and_decisions() {
1955 let (engine, _) = engine_with_decisions_in_order(&[]);
1958 append_thinking_entry(
1959 &engine,
1960 LedgerKind::Hypothesis,
1961 "led_conf_hyp",
1962 "confident hypothesis",
1963 Some(0.75),
1964 );
1965 append_thinking_entry(
1966 &engine,
1967 LedgerKind::Decision,
1968 "led_conf_dec",
1969 "confident decision",
1970 Some(0.6),
1971 );
1972
1973 let tmp = tempfile::tempdir().unwrap();
1974 export_all(&engine, tmp.path()).unwrap();
1975
1976 let thinking_body = std::fs::read_to_string(tmp.path().join("thinking.jsonl")).unwrap();
1978 assert!(
1979 thinking_body.contains("\"confidence\":0.75"),
1980 "export must serialize confidence; got:\n{thinking_body}"
1981 );
1982
1983 let engine2 = Engine::open_in_memory().unwrap();
1984 let sym = crate::schema::Symbol {
1985 symbol_id: "sym_order_test".into(),
1986 symbol_fp: "fp".into(),
1987 qname: "pkg.target".into(),
1988 language: "python".into(),
1989 kind: SymbolKind::Function,
1990 file: "src/target.py".into(),
1991 start: crate::schema::Position { line: 1, col: 0 },
1992 end: crate::schema::Position { line: 5, col: 0 },
1993 signature: None,
1994 doc: None,
1995 };
1996 AsgIndexStore::from_engine(&engine2)
1997 .put_symbol(&engine2.ref_name, &sym, "t")
1998 .unwrap();
1999 import_all(&engine2, tmp.path(), "t").unwrap();
2000
2001 let entries = AsgLedgerStore::from_engine(&engine2)
2002 .list_entries(&engine2.ref_name, "sym_order_test")
2003 .unwrap();
2004 let conf_of = |id: &str| {
2005 entries
2006 .iter()
2007 .find(|e| e.entry_id == id)
2008 .unwrap_or_else(|| panic!("{id} must import"))
2009 .confidence
2010 };
2011 assert_eq!(conf_of("led_conf_hyp"), Some(0.75));
2012 assert_eq!(conf_of("led_conf_dec"), Some(0.6));
2013 }
2014
2015 fn rec(id: &str, summary: &str) -> ExportRecord {
2016 ExportRecord {
2017 id: id.into(),
2018 kind: "decision".into(),
2019 qname: "App.A".into(),
2020 file: "a.rs".into(),
2021 summary: summary.into(),
2022 body: None,
2023 confidence: None,
2024 role: None,
2025 command: None,
2026 tags: vec![],
2027 evidence: vec![],
2028 supersedes: vec![],
2029 author: "agent:c".into(),
2030 created_at: "2026-05-19T20:00:00+00:00".into(),
2031 }
2032 }
2033
2034 #[test]
2035 fn merge_unions_dedups_and_sorts_by_id() {
2036 let tmp = tempdir().unwrap();
2037 let ours = tmp.path().join("ours.jsonl");
2038 let theirs = tmp.path().join("theirs.jsonl");
2039 write_jsonl(&ours, &[rec("led_b", "b"), rec("led_a", "a")]).unwrap();
2041 write_jsonl(&theirs, &[rec("led_a", "a"), rec("led_c", "c")]).unwrap();
2042
2043 let n = merge_jsonl(&ours, &theirs).unwrap();
2044 assert_eq!(n, 3, "union of {{a,b}} and {{a,c}} is {{a,b,c}}");
2045
2046 let expected_path = tmp.path().join("expected.jsonl");
2048 write_jsonl(
2049 &expected_path,
2050 &[rec("led_a", "a"), rec("led_b", "b"), rec("led_c", "c")],
2051 )
2052 .unwrap();
2053 assert_eq!(
2054 std::fs::read(&ours).unwrap(),
2055 std::fs::read(&expected_path).unwrap(),
2056 "merge output must match canonical id-sorted export bytes"
2057 );
2058 }
2059
2060 #[test]
2061 fn merge_is_idempotent() {
2062 let tmp = tempdir().unwrap();
2063 let ours = tmp.path().join("ours.jsonl");
2064 let theirs = tmp.path().join("theirs.jsonl");
2065 write_jsonl(&ours, &[rec("led_a", "a")]).unwrap();
2066 write_jsonl(&theirs, &[rec("led_b", "b")]).unwrap();
2067 merge_jsonl(&ours, &theirs).unwrap();
2068 let once = std::fs::read(&ours).unwrap();
2069 merge_jsonl(&ours, &theirs).unwrap();
2070 assert_eq!(once, std::fs::read(&ours).unwrap());
2071 }
2072
2073 #[test]
2074 fn merge_treats_missing_side_as_empty() {
2075 let tmp = tempdir().unwrap();
2076 let ours = tmp.path().join("ours.jsonl");
2077 let missing = tmp.path().join("does_not_exist.jsonl");
2078 write_jsonl(&ours, &[rec("led_b", "b"), rec("led_a", "a")]).unwrap();
2079 let n = merge_jsonl(&ours, &missing).unwrap();
2080 assert_eq!(n, 2);
2081 let expected = tmp.path().join("expected.jsonl");
2083 write_jsonl(&expected, &[rec("led_a", "a"), rec("led_b", "b")]).unwrap();
2084 assert_eq!(
2085 std::fs::read(&ours).unwrap(),
2086 std::fs::read(&expected).unwrap()
2087 );
2088 }
2089}
2090
2091#[cfg(test)]
2092mod additive_export_tests {
2093 use super::*;
2094 use std::collections::HashSet;
2095
2096 fn rec(id: &str, summary: &str) -> ExportRecord {
2097 ExportRecord {
2098 id: id.to_string(),
2099 kind: "decision".to_string(),
2100 qname: "pkg.thing".to_string(),
2101 file: "src/lib.rs".to_string(),
2102 summary: summary.to_string(),
2103 body: None,
2104 confidence: None,
2105 role: None,
2106 command: None,
2107 tags: Vec::new(),
2108 evidence: Vec::new(),
2109 supersedes: Vec::new(),
2110 author: "test".to_string(),
2111 created_at: "2026-01-01T00:00:00Z".to_string(),
2112 }
2113 }
2114
2115 fn committed(dir: &std::path::Path, recs: &[ExportRecord]) -> std::path::PathBuf {
2116 let p = dir.join("decisions.jsonl");
2117 write_jsonl(&p, recs).expect("seed committed file");
2118 p
2119 }
2120
2121 #[test]
2122 fn preserves_a_record_this_clone_cannot_produce() {
2123 let d = tempfile::tempdir().unwrap();
2127 let path = committed(
2128 d.path(),
2129 &[rec("led_orphan", "from another era"), rec("led_a", "old")],
2130 );
2131 let fresh = vec![rec("led_a", "current")];
2132
2133 let out = merge_preserving(&fresh, &path, &HashSet::new()).unwrap();
2134 let ids: Vec<&str> = out.iter().map(|r| r.id.as_str()).collect();
2135 assert_eq!(ids, vec!["led_a", "led_orphan"], "orphan was dropped");
2136 assert_eq!(out[0].summary, "current");
2138 }
2139
2140 #[test]
2141 fn does_not_resurrect_a_superseded_record() {
2142 let d = tempfile::tempdir().unwrap();
2144 let path = committed(
2145 d.path(),
2146 &[rec("led_old", "superseded"), rec("led_new", "current")],
2147 );
2148 let fresh = vec![rec("led_new", "current")];
2149 let retired: HashSet<String> = ["led_old".to_string()].into_iter().collect();
2150
2151 let out = merge_preserving(&fresh, &path, &retired).unwrap();
2152 let ids: Vec<&str> = out.iter().map(|r| r.id.as_str()).collect();
2153 assert_eq!(
2154 ids,
2155 vec!["led_new"],
2156 "a deliberately retired record came back"
2157 );
2158 }
2159
2160 #[test]
2161 fn does_not_resurrect_a_below_floor_hypothesis() {
2162 let d = tempfile::tempdir().unwrap();
2165 let path = committed(d.path(), &[rec("led_weak", "speculative")]);
2166 let retired: HashSet<String> = ["led_weak".to_string()].into_iter().collect();
2167
2168 let out = merge_preserving(&[], &path, &retired).unwrap();
2169 assert!(
2170 out.is_empty(),
2171 "below-floor hypothesis resurrected: {out:?}"
2172 );
2173 }
2174
2175 #[test]
2176 fn output_stays_id_sorted_and_deduped() {
2177 let d = tempfile::tempdir().unwrap();
2180 let path = committed(d.path(), &[rec("led_c", "c"), rec("led_a", "a")]);
2181 let fresh = vec![rec("led_b", "b"), rec("led_a", "a2")];
2182
2183 let out = merge_preserving(&fresh, &path, &HashSet::new()).unwrap();
2184 let ids: Vec<&str> = out.iter().map(|r| r.id.as_str()).collect();
2185 assert_eq!(ids, vec!["led_a", "led_b", "led_c"]);
2186 assert_eq!(out.len(), 3, "duplicate ids survived");
2187 assert_eq!(out[0].summary, "a2", "fresh should win on conflict");
2188 }
2189
2190 #[test]
2191 fn a_missing_committed_file_is_not_an_error() {
2192 let d = tempfile::tempdir().unwrap();
2194 let path = d.path().join("decisions.jsonl");
2195 let out = merge_preserving(&[rec("led_a", "a")], &path, &HashSet::new()).unwrap();
2196 assert_eq!(out.len(), 1);
2197 }
2198}