1use crate::ContextCompiler;
25use scc_core::{
26 choose_representation, estimate_tokens, fnv1a64_hex, kinds, predicates, ContentHandle,
27 HandleError, HandleKind, Relationship, RepresentationKind, StructuralSourceUnit,
28};
29use std::collections::BTreeMap;
30use std::path::Path;
31
32const STRUCTURAL: &str = "STRUCTURAL";
34const SIGNATURES: &str = "SIGNATURES";
36const EXACT: &str = "EXACT";
38
39const ATTR_CALL_ORDER: &str = "call_order";
41const ATTR_CALL_BLOCKS: &str = "call_blocks";
42const ATTR_CONDITIONAL_CALLS: &str = "conditional_calls";
43const ATTR_CALL_RETURNS: &str = "call_returns";
44
45const EDGE_ORDER: &[(&str, &str)] = &[
48 (predicates::CALLS, "CALL"),
49 (predicates::READS, "READ"),
50 (predicates::QUERIES, "QUERY"),
51 (predicates::WRITES, "WRITE"),
52 (predicates::TRANSFORMS, "TRANSFORM"),
53 (predicates::PUBLISHES, "EVENT"),
54 (predicates::SUBSCRIBES, "QUEUE"),
55 (predicates::REGISTERS, "CONTRACT"),
56 (predicates::CONSUMES, "CONTRACT"),
57 (predicates::PRODUCES, "CONTRACT"),
58 (predicates::PARTICIPATES_IN, "CONTRACT"),
59];
60
61const CONTRACT_VERBS: &[&str] = &[
63 predicates::REGISTERS,
64 predicates::CONSUMES,
65 predicates::PRODUCES,
66 predicates::PARTICIPATES_IN,
67];
68
69const LOGGING_TOKENS: &[&str] = &["log", "debug", "info", "warn", "print"];
72
73fn control_verb(kind: &str) -> String {
76 match kind {
77 "if" | "else" => "IF".to_string(),
78 "for" | "while" | "do" | "loop" => "LOOP".to_string(),
79 "try" | "catch" | "finally" => "TRY".to_string(),
80 "match" | "switch" => "MATCH".to_string(),
81 other => other.to_uppercase(),
82 }
83}
84
85fn evidence_line(compiler: &ContextCompiler, edge: &Relationship, default: u32) -> u32 {
89 let evmap = compiler.evidence_map();
90 let mut min: Option<u32> = None;
91 for id in &edge.evidence {
92 if let Some(ev) = evmap.get(id) {
93 if let Some(l) = ev.start_line {
94 min = Some(min.map_or(l, |m| m.min(l)));
95 }
96 }
97 }
98 min.unwrap_or(default)
99}
100
101fn first_sentence(doc: &str) -> String {
104 let doc = doc.trim();
105 if doc.is_empty() {
106 return String::new();
107 }
108 let mut end = doc.len();
109 for (i, c) in doc.char_indices() {
110 if c == '\n' {
111 end = i;
112 break;
113 }
114 if c == '.' {
115 let rest = &doc[i + 1..];
116 if rest.is_empty() || rest.starts_with(' ') || rest.starts_with('\n') {
117 end = i + 1;
118 break;
119 }
120 }
121 }
122 doc[..end].trim().to_string()
123}
124
125fn is_logging(name: &str) -> bool {
128 let lower = name.to_lowercase();
129 LOGGING_TOKENS.iter().any(|t| lower.contains(t))
130}
131
132fn has_cfg_evidence(sym: &scc_core::Entity) -> bool {
135 [ATTR_CALL_ORDER, ATTR_CALL_BLOCKS, ATTR_CONDITIONAL_CALLS]
136 .iter()
137 .any(|a| sym.attributes.contains_key(*a))
138}
139
140fn attr_u32(sym: &scc_core::Entity, key: &str) -> u32 {
142 sym.attributes
143 .get(key)
144 .and_then(|v| v.as_u64())
145 .unwrap_or(0) as u32
146}
147
148fn attr_str<'e>(sym: &'e scc_core::Entity, key: &str) -> Option<&'e str> {
150 sym.attributes.get(key).and_then(|v| v.as_str())
151}
152
153fn symbol_body(
156 compiler: &ContextCompiler,
157 sym: &scc_core::Entity,
158) -> Vec<String> {
159 let view = &compiler.view;
160 let sym_start = attr_u32(sym, "start_line");
161 let name_of = |id: &str| view.name_of(id);
162
163 let call_blocks: BTreeMap<String, String> = sym
165 .attributes
166 .get(ATTR_CALL_BLOCKS)
167 .and_then(|v| serde_json::from_value(v.clone()).ok())
168 .unwrap_or_default();
169 let call_returns: std::collections::BTreeSet<String> = sym
171 .attributes
172 .get(ATTR_CALL_RETURNS)
173 .and_then(|v| serde_json::from_value(v.clone()).ok())
174 .unwrap_or_default();
175
176 let mut raw: Vec<(u32, usize, String, String)> = Vec::new();
178 for (rank, (pred, verb)) in EDGE_ORDER.iter().enumerate() {
179 for edge in view.out_pred(&sym.id, pred) {
180 let target = view.entity(&edge.object);
181 let name = name_of(&edge.object);
182 if CONTRACT_VERBS.contains(pred) {
183 let is_contract = target.map(|t| t.kind == kinds::CONTRACT).unwrap_or(false);
184 if !is_contract {
185 continue;
186 }
187 }
188 let line = evidence_line(compiler, edge, sym_start);
189 if *pred == predicates::CALLS {
190 if is_logging(&name) {
191 continue;
192 }
193 let is_ctor = target.map(|t| t.kind == "class").unwrap_or(false);
194 let v = if is_ctor { "CONSTRUCT" } else { "CALL" };
195 raw.push((line, rank, name.clone(), format!("{v} {name}")));
196 } else {
197 raw.push((line, rank, name.clone(), format!("{verb} {name}")));
198 }
199 }
200 }
201 raw.sort();
202 let mut dedup: Vec<(u32, usize, String, String)> = Vec::new();
205 for r in raw {
206 if dedup.last().map(|d| d.3 == r.3).unwrap_or(false) {
207 continue;
208 }
209 dedup.push(r);
210 }
211
212 let mut out: Vec<String> = Vec::new();
215 for (_, _, name, rendered) in &dedup {
216 let is_call = rendered.starts_with("CALL ") || rendered.starts_with("CONSTRUCT ");
217 if is_call {
218 if let Some(kind) = call_blocks.get(name) {
219 let ctl = format!(" {} {name}", control_verb(kind));
220 if out.last().map(|l| l == &ctl).unwrap_or(false) {
221 } else {
223 out.push(ctl);
224 }
225 out.push(format!(" {rendered}"));
226 } else {
227 out.push(format!(" {rendered}"));
228 }
229 if call_returns.contains(name) {
230 let ret = format!(" RETURN {name}");
231 if out.last().map(|l| l == &ret).unwrap_or(false) {
232 } else {
234 out.push(ret);
235 }
236 }
237 } else {
238 out.push(format!(" {rendered}"));
239 }
240 }
241 let mut lines: Vec<String> = Vec::new();
243 for l in out {
244 if lines.last().map(|p| p == &l).unwrap_or(false) {
245 continue;
246 }
247 lines.push(l);
248 }
249 lines
250}
251
252fn file_symbols<'c>(compiler: &'c ContextCompiler<'_>, file_id: &str) -> Vec<&'c scc_core::Entity> {
256 let view = &compiler.view;
257 let mut syms: Vec<&scc_core::Entity> = view
258 .out_pred(file_id, predicates::CONTAINS)
259 .iter()
260 .filter_map(|r| view.entity(&r.object))
261 .filter(|e| e.kind == kinds::SYMBOL)
262 .collect();
263 syms.sort_by(|a, b| {
264 let la = attr_u32(a, "start_line");
265 let lb = attr_u32(b, "start_line");
266 (la, &a.name).cmp(&(lb, &b.name))
267 });
268 syms
269}
270
271fn build_unit(compiler: &ContextCompiler, path: &str) -> Option<StructuralSourceUnit> {
275 let repo = &compiler.view.graph.repo_id;
276 let file_id = scc_core::entity_id(repo, kinds::FILE, path);
277 let syms = file_symbols(compiler, &file_id);
278 if syms.is_empty() {
279 return None;
280 }
281
282 let deep = syms.iter().any(|s| has_cfg_evidence(s));
283 let (min_line, max_line) = syms.iter().fold((u32::MAX, 0u32), |(mn, mx), s| {
284 let start = attr_u32(s, "start_line");
285 let end = attr_u32(s, "end_line").max(start);
286 (mn.min(start), mx.max(end))
287 });
288 let (min_line, max_line) = if min_line == u32::MAX { (0, 0) } else { (min_line, max_line) };
289
290 let mut content = String::new();
291 if deep {
292 for sym in &syms {
293 if !content.is_empty() {
294 content.push('\n');
295 }
296 let sig = attr_str(sym, "signature")
297 .map(|s| s.to_string())
298 .unwrap_or_else(|| {
299 let k = attr_str(sym, "kind").unwrap_or(&sym.kind);
300 format!("{k} {}", sym.name)
301 });
302 content.push_str(&sig);
303 content.push('\n');
304 if let Some(doc) = attr_str(sym, "docstring") {
305 let sentence = first_sentence(doc);
306 if !sentence.is_empty() {
307 content.push_str("# ");
308 content.push_str(&sentence);
309 content.push('\n');
310 }
311 }
312 let body = symbol_body(compiler, sym);
313 if !body.is_empty() {
314 content.push('\n');
315 for l in &body {
316 content.push_str(l);
317 content.push('\n');
318 }
319 }
320 }
321 } else {
322 let view = &compiler.view;
324 let mut imports: Vec<(u32, String)> = view
325 .out_pred(&file_id, predicates::IMPORTS)
326 .iter()
327 .map(|r| {
328 let line = evidence_line(compiler, r, 0);
329 let name = view.name_of(&r.object);
330 (line, format!("IMPORT {name}"))
331 })
332 .collect();
333 imports.sort();
334 let mut seen_imports: Vec<String> = Vec::new();
335 for (_, l) in imports {
336 if seen_imports.last().map(|p| p == &l).unwrap_or(false) {
337 continue;
338 }
339 seen_imports.push(l);
340 }
341 for l in seen_imports {
342 content.push_str(&l);
343 content.push('\n');
344 }
345 for sym in &syms {
346 if !content.is_empty() {
347 content.push('\n');
348 }
349 let sig = attr_str(sym, "signature")
350 .map(|s| s.to_string())
351 .unwrap_or_else(|| {
352 let k = attr_str(sym, "kind").unwrap_or(&sym.kind);
353 format!("{k} {}", sym.name)
354 });
355 content.push_str(&sig);
356 content.push('\n');
357 if let Some(doc) = attr_str(sym, "docstring") {
358 let sentence = first_sentence(doc);
359 if !sentence.is_empty() {
360 content.push_str("# ");
361 content.push_str(&sentence);
362 content.push('\n');
363 }
364 }
365 }
366 }
367 let content = content.trim_end().to_string();
368 let generated_kind = if deep { STRUCTURAL } else { SIGNATURES };
369 let (content, representation, representation_reason) = apply_representation_policy(
370 &compiler.store.root,
371 path,
372 min_line,
373 max_line,
374 content,
375 generated_kind,
376 );
377 let handle = file_handle(compiler, path);
378
379 Some(StructuralSourceUnit {
380 path: path.to_string(),
381 source: format!("source: {path}:L{min_line}-L{max_line}"),
382 representation,
383 revision: compiler.revision(),
384 content,
385 handle,
386 representation_reason,
387 })
388}
389
390fn apply_representation_policy(
395 root: &Path,
396 path: &str,
397 min_line: u32,
398 max_line: u32,
399 generated: String,
400 generated_kind: &str,
401) -> (String, String, String) {
402 let Some(exact) = read_exact_span(root, path, min_line, max_line) else {
403 return (generated, generated_kind.to_string(), String::new());
404 };
405 let choice = choose_representation(estimate_tokens(&exact), estimate_tokens(&generated));
406 match choice.kind {
407 RepresentationKind::Exact => (exact, EXACT.to_string(), choice.reason),
408 RepresentationKind::Structural | RepresentationKind::Signatures => {
409 (generated, generated_kind.to_string(), choice.reason)
410 }
411 }
412}
413
414fn read_exact_span(root: &Path, path: &str, start: u32, end: u32) -> Option<String> {
415 if start == 0 {
416 return None;
417 }
418 let text = crate::repo_path::read_repo_text(root, path)?;
419 let mut out = String::new();
420 for (i, line) in text.lines().enumerate() {
421 let n = (i + 1) as u32;
422 if n > end {
423 break;
424 }
425 if n >= start {
426 out.push_str(line);
427 out.push('\n');
428 }
429 }
430 if out.is_empty() {
431 None
432 } else {
433 Some(out.trim_end().to_string())
434 }
435}
436
437pub(crate) fn file_handle(compiler: &ContextCompiler, path: &str) -> String {
439 let hash = match crate::repo_path::read_repo_file(&compiler.store.root, path) {
440 Some(bytes) => fnv1a64_hex(&bytes),
441 None => String::new(),
442 };
443 ContentHandle::for_file(
444 &compiler.store.repo_id,
445 &compiler.revision(),
446 path,
447 &hash,
448 )
449 .to_string()
450}
451
452pub fn resolve_handle_to_path(root: &Path, spec: &str) -> Result<String, String> {
456 if !spec.starts_with("scc://") {
457 return Ok(spec.to_string());
458 }
459 let h = ContentHandle::parse(spec).map_err(|e| e.to_string())?;
460 let path = match h.kind {
461 HandleKind::File => h.key.clone(),
462 HandleKind::Symbol => h
463 .key
464 .split_once("::")
465 .map(|(p, _)| p.to_string())
466 .ok_or_else(|| "malformed symbol handle".to_string())?,
467 HandleKind::Span => h
468 .key
469 .split_once(":L")
470 .map(|(p, _)| p.to_string())
471 .ok_or_else(|| "malformed span handle".to_string())?,
472 other => {
473 return Err(format!(
474 "handle kind {} is not a source fetch",
475 other.as_str()
476 ))
477 }
478 };
479 let bytes = crate::repo_path::read_repo_file(root, &path)
480 .ok_or_else(|| format!("handle target missing: {path}"))?;
481 h.refuse_if_stale(&fnv1a64_hex(&bytes))
482 .map_err(|e: HandleError| e.to_string())?;
483 Ok(path)
484}
485
486pub fn structural_source(
491 compiler: &ContextCompiler,
492 paths: &[String],
493 max_units: usize,
494) -> Vec<StructuralSourceUnit> {
495 let mut units: Vec<StructuralSourceUnit> = Vec::new();
496 if max_units == 0 {
497 return units;
498 }
499 let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
500 for p in paths {
501 if units.len() >= max_units {
502 break;
503 }
504 if seen.contains(p) {
505 continue;
506 }
507 seen.insert(p.clone());
508 if let Some(u) = build_unit(compiler, p) {
509 units.push(u);
510 }
511 }
512 units
513}
514
515pub fn render_structural(units: &[StructuralSourceUnit]) -> String {
532 let mut out = String::new();
533 for (i, u) in units.iter().enumerate() {
534 if i > 0 {
535 out.push('\n');
536 }
537 out.push_str(&u.path);
538 out.push_str("\n\n");
539 out.push_str(&u.source);
540 out.push('\n');
541 out.push_str("representation: ");
542 out.push_str(&u.representation);
543 out.push('\n');
544 if !u.representation_reason.is_empty() {
545 out.push_str("reason: ");
546 out.push_str(&u.representation_reason);
547 out.push('\n');
548 }
549 if !u.handle.is_empty() {
550 out.push_str("handle: ");
551 out.push_str(&u.handle);
552 out.push('\n');
553 }
554 out.push_str("revision: ");
555 out.push_str(&u.revision);
556 out.push_str("\n\n");
557 out.push_str(&u.content);
558 out.push('\n');
559 }
560 out.trim_end().to_string()
561}
562
563#[cfg(test)]
564mod tests {
565 use super::*;
566 use scc_core::{
567 entity_id, kinds, predicates, relationship_id, symbol_id, Entity, Evidence,
568 EvidenceType, Provenance, Relationship,
569 };
570 use scc_graph::RealityGraph;
571 use scc_store::Store;
572 use std::collections::HashMap;
573
574fn evidence(id: &str, path: &str, line: u32) -> Evidence {
576 let mut e = Evidence::source(id, path);
577 e.start_line = Some(line);
578 e.r#type = EvidenceType::Source;
579 e
580 }
581
582 fn fixture() -> (tempfile::TempDir, Store, RealityGraph) {
590 let dir = tempfile::TempDir::new().unwrap();
591 let root = dir.path().join("repo");
592 std::fs::create_dir_all(&root).unwrap();
593 let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
594 let repo = "repo";
595 store.upsert_file("app.py", "h1", "python", "source", 20).unwrap();
596 store.upsert_file("lib.py", "h2", "python", "source", 20).unwrap();
597
598 let file_id = entity_id(repo, kinds::FILE, "app.py");
599 let lib_id = entity_id(repo, kinds::FILE, "lib.py");
600 let mk = |n: &str| symbol_id(repo, "app.py", n);
601 let db_id = entity_id(repo, kinds::DATA_STORE, "db");
602
603 let mut entities: HashMap<String, Entity> = HashMap::new();
604
605 let mut handler = Entity::new(mk("handler"), kinds::SYMBOL, "handler");
607 handler.attr("file", serde_json::json!("app.py"));
608 handler.attr("signature", serde_json::json!("def handler(x: str) -> str:"));
609 handler.attr("start_line", serde_json::json!(10));
610 handler.attr("end_line", serde_json::json!(14));
611 handler.attr(
612 "docstring",
613 serde_json::json!("Normalizes a raw transcript. Deprecated: use normalize_v2."),
614 );
615 handler.attr("call_order", serde_json::json!({"worker": 0, "debug_log": 1}));
616 handler.attr("call_blocks", serde_json::json!({"worker": "if"}));
617 handler.attr("call_returns", serde_json::json!(["worker"]));
618 entities.insert(handler.id.clone(), handler);
619
620 for n in ["worker", "debug_log"] {
622 let mut s = Entity::new(mk(n), kinds::SYMBOL, n);
623 s.attr("file", serde_json::json!("app.py"));
624 s.attr("signature", serde_json::json!(format!("def {n}(y):")));
625 s.attr("start_line", serde_json::json!(20));
626 s.attr("end_line", serde_json::json!(21));
627 entities.insert(s.id.clone(), s);
628 }
629
630 let mut de = Entity::new(db_id.clone(), kinds::DATA_STORE, "db");
632 de.attr("technology", serde_json::json!("postgres"));
633 entities.insert(de.id.clone(), de);
634
635 let reader_id = symbol_id(repo, "lib.py", "reader");
637 let mut reader = Entity::new(reader_id.clone(), kinds::SYMBOL, "reader");
638 reader.attr("file", serde_json::json!("lib.py"));
639 reader.attr("signature", serde_json::json!("def reader(path: str) -> list:"));
640 reader.attr("start_line", serde_json::json!(3));
641 reader.attr("end_line", serde_json::json!(8));
642 reader.attr("docstring", serde_json::json!("Reads lines from a file."));
643 entities.insert(reader.id.clone(), reader);
644
645 let util_id = entity_id(repo, kinds::EXTERNAL_API, "util");
647 entities.insert(
648 util_id.clone(),
649 Entity::new(util_id.clone(), kinds::EXTERNAL_API, "util"),
650 );
651
652 let mut out: HashMap<String, Vec<Relationship>> = HashMap::new();
653 let mut inn: HashMap<String, Vec<Relationship>> = HashMap::new();
654 let mut n = 0u64;
655 let mut rel = |s: String,
656 pred: &str,
657 o: String,
658 ev: Vec<String>,
659 inn: &mut HashMap<String, Vec<Relationship>>| {
660 n += 1;
661 let r = Relationship::new(
662 relationship_id(n),
663 s.clone(),
664 pred,
665 o,
666 Provenance::Extracted,
667 )
668 .with_evidence(ev);
669 out.entry(s.clone()).or_default().push(r.clone());
670 inn.entry(r.object.clone()).or_default().push(r);
671 };
672
673 store.insert_evidence(&evidence("evidence:10", "app.py", 12)).unwrap();
675 store.insert_evidence(&evidence("evidence:11", "app.py", 13)).unwrap();
676 store.insert_evidence(&evidence("evidence:12", "app.py", 14)).unwrap();
677 store.insert_evidence(&evidence("evidence:13", "app.py", 12)).unwrap();
678 store.insert_evidence(&evidence("evidence:20", "lib.py", 1)).unwrap();
679 let sev = Evidence::source("evidence:21", "app.py");
680 store.insert_evidence(&sev).unwrap();
681
682 rel(
684 file_id.clone(),
685 predicates::CONTAINS,
686 mk("handler"),
687 vec!["evidence:21".to_string()],
688 &mut inn,
689 );
690 rel(
692 mk("handler"),
693 predicates::CALLS,
694 mk("worker"),
695 vec!["evidence:10".to_string()],
696 &mut inn,
697 );
698 rel(
699 mk("handler"),
700 predicates::CALLS,
701 mk("worker"),
702 vec!["evidence:13".to_string()],
703 &mut inn,
704 );
705 rel(
706 mk("handler"),
707 predicates::CALLS,
708 mk("debug_log"),
709 vec!["evidence:11".to_string()],
710 &mut inn,
711 );
712 rel(
714 mk("handler"),
715 predicates::WRITES,
716 db_id.clone(),
717 vec!["evidence:12".to_string()],
718 &mut inn,
719 );
720 rel(
722 lib_id.clone(),
723 predicates::CONTAINS,
724 reader_id,
725 vec!["evidence:20".to_string()],
726 &mut inn,
727 );
728 rel(
729 lib_id.clone(),
730 predicates::IMPORTS,
731 util_id,
732 vec!["evidence:20".to_string()],
733 &mut inn,
734 );
735
736 let graph = RealityGraph {
737 repo_id: repo.to_string(),
738 entities,
739 out,
740 inn,
741 components: vec![],
742 flows: vec![],
743 invariants: vec![],
744 };
745 (dir, store, graph)
746 }
747
748fn compiler<'a>(
750 store: &'a Store,
751 graph: &'a RealityGraph,
752 ) -> ContextCompiler<'a> {
753 ContextCompiler::new(
754 store,
755 graph,
756 crate::ContextSettings::default(),
757 Vec::new(),
758 )
759 }
760
761 #[test]
762fn structural_unit_has_call_write_return_in_evidence_order() {
764 let (_d, store, graph) = fixture();
765 let c = compiler(&store, &graph);
766 let units = structural_source(&c, &["app.py".to_string()], 10);
767
768 assert_eq!(units.len(), 1, "one unit for app.py");
769 let u = &units[0];
770 assert_eq!(u.path, "app.py");
771 assert_eq!(u.source, "source: app.py:L10-L14");
773 assert_eq!(u.representation, "STRUCTURAL");
774 assert_eq!(u.revision, c.revision());
775
776 let content = &u.content;
777 assert!(content.contains("def handler(x: str) -> str:"), "{content}");
779 assert!(content.contains("# Normalizes a raw transcript"), "{content}");
780 assert!(!content.contains("Deprecated"), "{content}");
781 assert!(content.contains(" IF worker"), "{content}");
783 assert!(content.contains(" CALL worker"), "{content}");
784 assert!(content.contains(" RETURN worker"), "{content}");
785 assert!(content.contains(" WRITE db"), "{content}");
786 assert!(!content.contains("debug_log"), "{content}");
788 assert_eq!(content.matches("CALL worker").count(), 1, "{content}");
790 let call_i = content.find("CALL worker").unwrap();
792 let write_i = content.find("WRITE db").unwrap();
793 let ctl_i = content.find("IF worker").unwrap();
794 assert!(ctl_i < call_i && call_i < write_i, "{content}");
795 }
796
797 #[test]
798fn fallback_produces_signatures_for_file_without_cfg_evidence() {
800 let (_d, store, graph) = fixture();
801 let c = compiler(&store, &graph);
802 let units = structural_source(&c, &["lib.py".to_string()], 10);
803
804 assert_eq!(units.len(), 1);
805 let u = &units[0];
806 assert_eq!(u.path, "lib.py");
807 assert_eq!(u.source, "source: lib.py:L3-L8");
808 assert_eq!(u.representation, "SIGNATURES");
809 assert!(u.content.contains("IMPORT util"), "{}", u.content);
810 assert!(u.content.contains("def reader(path: str) -> list:"), "{}", u.content);
811 assert!(u.content.contains("# Reads lines from a file."), "{}", u.content);
812 assert!(!u.content.contains("CALL "), "{}", u.content);
814 assert!(!u.content.contains(" WRITE"), "{}", u.content);
815 }
816
817 #[test]
818fn render_structural_emits_provenance_header_per_unit() {
820 let (_d, store, graph) = fixture();
821 let c = compiler(&store, &graph);
822 let units = structural_source(
823 &c,
824 &["app.py".to_string(), "lib.py".to_string()],
825 10,
826 );
827 assert_eq!(units.len(), 2);
828 let rendered = render_structural(&units);
829 assert!(rendered.contains("app.py\n\nsource: app.py:L10-L14"), "{rendered}");
830 assert!(rendered.contains("representation: STRUCTURAL"), "{rendered}");
831 assert!(rendered.contains("lib.py\n\nsource: lib.py:L3-L8"), "{rendered}");
832 assert!(rendered.contains("representation: SIGNATURES"), "{rendered}");
833 assert!(rendered.contains("revision: "), "{rendered}");
834 assert!(rendered.contains(" CALL worker"), "{rendered}");
836 assert!(rendered.contains(" WRITE db"), "{rendered}");
837 }
838
839 #[test]
840fn unknown_and_duplicate_paths_are_handled_deterministically() {
842 let (_d, store, graph) = fixture();
843 let c = compiler(&store, &graph);
844 let units = structural_source(
847 &c,
848 &[
849 "nope.py".to_string(),
850 "app.py".to_string(),
851 "app.py".to_string(),
852 "lib.py".to_string(),
853 ],
854 1,
855 );
856 assert_eq!(units.len(), 1);
857 assert_eq!(units[0].path, "app.py");
858 let units = structural_source(&c, &["app.py".to_string()], 0);
859 assert!(units.is_empty());
860 }
861
862 #[test]
863 fn exact_wins_when_on_disk_body_is_cheaper() {
865 let (_d, store, graph) = fixture();
866 let mut body = String::new();
867 for _ in 1..10 {
868 body.push('\n');
869 }
870 body.push_str("def handler(x: str) -> str:\n return worker()\n");
871 std::fs::write(store.root.join("app.py"), body).unwrap();
872 let c = compiler(&store, &graph);
873 let units = structural_source(&c, &["app.py".to_string()], 10);
874 assert_eq!(units.len(), 1);
875 assert_eq!(units[0].representation, "EXACT");
876 assert_eq!(units[0].representation_reason, "exact_body_cheaper_than_structural");
877 assert!(units[0].content.contains("def handler"), "{}", units[0].content);
878 assert!(!units[0].handle.is_empty(), "handle must be stamped when file exists");
879 }
880
881 #[test]
882 fn stale_handle_refuses_without_guessing() {
884 let dir = tempfile::TempDir::new().unwrap();
885 let root = dir.path();
886 std::fs::write(root.join("a.py"), "print(1)\n").unwrap();
887 let fresh = ContentHandle::for_file("r", "e", "a.py", &fnv1a64_hex(b"print(1)\n"));
888 assert_eq!(
889 resolve_handle_to_path(root, &fresh.to_string()).unwrap(),
890 "a.py"
891 );
892 let stale = ContentHandle::for_file("r", "e", "a.py", "aaaaaaaaaaaaaaaa");
893 let err = resolve_handle_to_path(root, &stale.to_string()).unwrap_err();
894 assert!(err.contains("stale"), "{err}");
895 assert_eq!(resolve_handle_to_path(root, "a.py").unwrap(), "a.py");
896 }
897
898 #[test]
899 fn empty_file_handle_is_not_a_wildcard() {
901 let (_d, store, graph) = fixture();
902 std::fs::write(store.root.join("empty.py"), b"").unwrap();
903 let c = compiler(&store, &graph);
904 let rendered = file_handle(&c, "empty.py");
905 let parsed = ContentHandle::parse(&rendered).expect("handle");
906 let empty_hash = fnv1a64_hex(b"");
907 assert_eq!(parsed.content_hash, empty_hash);
908 assert!(!parsed.content_hash.is_empty());
909 assert!(parsed.matches_content(&empty_hash));
910 assert!(!parsed.matches_content(&fnv1a64_hex(b"later\n")));
911 }
912}