1use crate::schema::Symbol;
10use serde_json::{Value, json};
11
12pub fn brief_from_env() -> bool {
16 std::env::var("ASD_FORMAT")
17 .map(|v| v.eq_ignore_ascii_case("brief"))
18 .unwrap_or(false)
19}
20
21pub fn query_id(cmd: &str, args: &[&str]) -> String {
26 let mut s = String::with_capacity(cmd.len() + 32);
27 s.push_str(cmd);
28 for a in args {
29 s.push('|');
30 s.push_str(a);
31 }
32 let h = blake3::hash(s.as_bytes());
33 let hex = h.to_hex();
34 let prefix: String = hex.chars().take(7).collect();
35 format!("Q{prefix}")
36}
37
38pub fn brief_symbol(sym: &Symbol) -> Value {
40 let doc_line = sym
41 .doc
42 .as_deref()
43 .and_then(|d| d.lines().next().map(|l| l.trim().to_string()))
44 .filter(|l| !l.is_empty());
45 let mut out = serde_json::Map::new();
46 out.insert("qname".into(), Value::String(sym.qname.clone()));
47 out.insert(
48 "file".into(),
49 Value::String(format!("{}:{}", sym.file, sym.start.line)),
50 );
51 if let Some(sig) = &sym.signature {
52 out.insert("signature".into(), Value::String(sig.clone()));
53 }
54 if let Some(d) = doc_line {
55 out.insert("doc".into(), Value::String(d));
56 }
57 Value::Object(out)
58}
59
60pub fn brief_call_list(items: &[Value]) -> Vec<String> {
63 items
64 .iter()
65 .filter_map(|v| {
66 let q = v.get("qname").and_then(Value::as_str)?;
67 let f = v.get("file").and_then(Value::as_str)?;
68 let l = v.get("line").and_then(Value::as_u64).unwrap_or(0);
69 Some(format!("{q} ({f}:{l})"))
70 })
71 .collect()
72}
73
74pub fn brief_read(
77 sym: &Symbol,
78 callers: &[Value],
79 callees: &[Value],
80 effects: Option<&Value>,
81 ledger_count: usize,
82) -> Value {
83 let mut out = serde_json::Map::new();
84 out.insert("symbol".into(), brief_symbol(sym));
85 let caller_strs = brief_call_list(callers);
86 let callee_strs = brief_call_list(callees);
87 if !caller_strs.is_empty() {
88 out.insert("callers".into(), json!(caller_strs));
89 }
90 if !callee_strs.is_empty() {
91 out.insert("callees".into(), json!(callee_strs));
92 }
93 if let Some(eff) = effects {
94 if let Some(declared) = eff.get("declared").and_then(Value::as_array) {
95 let cats: Vec<String> = declared
96 .iter()
97 .filter_map(|e| e.get("effect").and_then(Value::as_str).map(String::from))
98 .collect();
99 if !cats.is_empty() {
100 out.insert("effects".into(), json!(cats));
101 }
102 }
103 }
104 if ledger_count > 0 {
105 out.insert("ledger_count".into(), json!(ledger_count));
106 }
107 Value::Object(out)
108}
109
110pub fn brief_search_results(hits: &[Value]) -> Vec<Value> {
113 hits.iter()
114 .map(|hit| {
115 let mut out = serde_json::Map::new();
116 if let Some(q) = hit.get("qname").and_then(Value::as_str) {
117 out.insert("qname".into(), Value::String(q.into()));
118 }
119 let file = hit.get("file").and_then(Value::as_str).unwrap_or("");
120 let line = hit.get("line").and_then(Value::as_u64).unwrap_or(0);
121 if !file.is_empty() {
122 out.insert("file".into(), Value::String(format!("{file}:{line}")));
123 }
124 if let Some(s) = hit.get("signature").and_then(Value::as_str) {
125 out.insert("signature".into(), Value::String(s.into()));
126 }
127 if let Some(d) = hit
128 .get("doc")
129 .and_then(Value::as_str)
130 .and_then(|s| s.lines().next())
131 {
132 let d = d.trim();
133 if !d.is_empty() {
134 out.insert("doc".into(), Value::String(d.into()));
135 }
136 }
137 if let Some(score) = hit.get("score") {
138 out.insert("score".into(), score.clone());
139 } else if let Some(score) = hit.get("bm25_score") {
140 out.insert("score".into(), score.clone());
141 }
142 Value::Object(out)
143 })
144 .collect()
145}
146
147pub fn brief_investigate(full: &Value) -> Value {
153 let mut out = serde_json::Map::new();
154 let entry_points: Vec<Value> =
157 if let Some(eps) = full.get("entry_points").and_then(Value::as_array) {
158 eps.clone()
159 } else if let Some(by_layer) = full.get("by_layer").and_then(Value::as_object) {
160 by_layer
162 .values()
163 .filter_map(|v| v.as_array().cloned())
164 .flatten()
165 .collect()
166 } else {
167 Vec::new()
168 };
169 if !entry_points.is_empty() {
170 out.insert(
171 "entry_points".into(),
172 json!(brief_search_results(&entry_points)),
173 );
174 }
175 for k in ["safe_change_recipe", "stale", "query_id"] {
176 if let Some(v) = full.get(k) {
177 if !v.is_null() {
178 out.insert(k.into(), v.clone());
179 }
180 }
181 }
182 crate::ser_helpers::drop_empty_top_level(Value::Object(out))
183}
184
185pub fn brief_prepare_change(full: &Value) -> Value {
191 let mut out = serde_json::Map::new();
192 for k in ["intent", "focus", "query_id", "stale"] {
197 if let Some(v) = full.get(k) {
198 if !v.is_null() {
199 out.insert(k.into(), v.clone());
200 }
201 }
202 }
203 if let Some(arr) = full.get("likely_edit_files").and_then(Value::as_array) {
205 let compact: Vec<Value> = arr
206 .iter()
207 .map(|f| {
208 let mut o = serde_json::Map::new();
209 for k in ["file", "why", "top_symbol", "layer"] {
210 if let Some(v) = f.get(k) {
211 if !v.is_null() {
212 o.insert(k.into(), v.clone());
213 }
214 }
215 }
216 Value::Object(o)
217 })
218 .collect();
219 out.insert("likely_edit_files".into(), json!(compact));
220 }
221 for k in [
222 "safe_change_recipe",
223 "design_invariants",
224 "known_hazards",
225 "prior_thinking",
226 ] {
227 if let Some(v) = full.get(k) {
228 let nonempty = v.as_array().map(|a| !a.is_empty()).unwrap_or(false)
229 || v.as_object().map(|o| !o.is_empty()).unwrap_or(false);
230 if nonempty {
231 out.insert(k.into(), v.clone());
232 }
233 }
234 }
235 if let Some(v) = full.get("thinking_summary") {
243 out.insert("thinking_summary".into(), v.clone());
244 }
245 crate::ser_helpers::drop_empty_top_level(Value::Object(out))
246}
247
248pub fn brief_context_for(full: &Value) -> Value {
252 let mut out = serde_json::Map::new();
253 if let Some(sym) = full.get("symbol") {
254 let mut s = serde_json::Map::new();
257 for k in ["qname", "file", "line", "signature", "doc"] {
258 if let Some(v) = sym.get(k) {
259 if !v.is_null() {
260 s.insert(k.into(), v.clone());
261 }
262 }
263 }
264 out.insert("symbol".into(), Value::Object(s));
265 }
266 for (k, max) in [("callers", 3usize), ("callees", 3usize)] {
267 if let Some(arr) = full.get(k).and_then(Value::as_array) {
268 let lines = brief_call_list(arr);
269 let capped: Vec<String> = lines.into_iter().take(max).collect();
270 if !capped.is_empty() {
271 out.insert(k.into(), json!(capped));
272 }
273 }
274 }
275 if let Some(eff) = full.get("effects") {
276 if let Some(declared) = eff.get("declared").and_then(Value::as_array) {
277 let cats: Vec<String> = declared
278 .iter()
279 .filter_map(|e| e.get("effect").and_then(Value::as_str).map(String::from))
280 .collect();
281 if !cats.is_empty() {
282 out.insert("effects".into(), json!(cats));
283 }
284 }
285 }
286 let ledger_count = full
289 .get("ledger")
290 .or_else(|| full.get("decisions_and_notes"))
291 .and_then(Value::as_array)
292 .map(|a| a.len())
293 .unwrap_or(0);
294 if ledger_count > 0 {
295 out.insert("ledger_count".into(), json!(ledger_count));
296 }
297 Value::Object(out)
298}
299
300pub fn brief_conclusions_list(full: &Value) -> Value {
303 let mut out = serde_json::Map::new();
304 for k in ["class", "symbol", "total"] {
305 if let Some(v) = full.get(k) {
306 if !v.is_null() {
307 out.insert(k.into(), v.clone());
308 }
309 }
310 }
311 if let Some(buckets) = full.get("buckets").and_then(Value::as_object) {
312 let compact: serde_json::Map<String, Value> = buckets
313 .iter()
314 .map(|(stem, entries)| {
315 let arr = entries.as_array().cloned().unwrap_or_default();
316 let trimmed: Vec<Value> = arr
317 .iter()
318 .map(|e| {
319 let mut o = serde_json::Map::new();
320 for k in [
321 "entry_id",
322 "kind",
323 "qname",
324 "summary",
325 "role",
326 "command",
327 "created_at",
328 ] {
329 if let Some(v) = e.get(k) {
330 if !v.is_null() {
331 o.insert(k.into(), v.clone());
332 }
333 }
334 }
335 if let Some(tags) = e.get("tags").and_then(Value::as_array) {
336 if !tags.is_empty() {
337 o.insert("tags".into(), json!(tags));
338 }
339 }
340 Value::Object(o)
341 })
342 .collect();
343 (stem.clone(), json!(trimmed))
344 })
345 .collect();
346 out.insert("buckets".into(), Value::Object(compact));
347 }
348 Value::Object(out)
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354 use crate::schema::{Position, Symbol, SymbolKind};
355
356 fn sample_symbol() -> Symbol {
357 Symbol {
358 symbol_id: "sym_abc".into(),
359 symbol_fp: "fp_xyz".into(),
360 qname: "store.pricing.get_rate".into(),
361 language: "python".into(),
362 kind: SymbolKind::Function,
363 file: "store/pricing.py".into(),
364 start: Position { line: 14, col: 1 },
365 end: Position { line: 23, col: 41 },
366 signature: Some("def get_rate(region: str) -> float".into()),
367 doc: Some("Return the sales-tax rate for region.\nLong description.".into()),
368 }
369 }
370
371 #[test]
372 fn brief_symbol_drops_noise_fields() {
373 let s = sample_symbol();
374 let v = brief_symbol(&s);
375 let obj = v.as_object().unwrap();
376 assert_eq!(
377 obj.get("qname").and_then(Value::as_str),
378 Some("store.pricing.get_rate")
379 );
380 assert_eq!(
381 obj.get("file").and_then(Value::as_str),
382 Some("store/pricing.py:14")
383 );
384 for noise in [
385 "symbol_id",
386 "symbol_fp",
387 "language",
388 "kind",
389 "col",
390 "end",
391 "start",
392 ] {
393 assert!(obj.get(noise).is_none(), "brief symbol must drop `{noise}`");
394 }
395 }
396
397 #[test]
398 fn brief_symbol_omits_empty_doc_and_signature() {
399 let mut s = sample_symbol();
400 s.doc = None;
401 s.signature = None;
402 let v = brief_symbol(&s);
403 let obj = v.as_object().unwrap();
404 assert!(obj.get("doc").is_none());
405 assert!(obj.get("signature").is_none());
406 }
407
408 #[test]
409 fn brief_call_list_compacts_to_one_liners() {
410 let calls = vec![
411 json!({"qname": "a.b.c", "file": "a.py", "line": 5}),
412 json!({"qname": "x.y", "file": "x.py", "line": 12}),
413 ];
414 let out = brief_call_list(&calls);
415 assert_eq!(out, vec!["a.b.c (a.py:5)", "x.y (x.py:12)"]);
416 }
417
418 #[test]
419 fn brief_search_results_projects_fts_hit_shape() {
420 let hits = vec![json!({
421 "qname": "a.b",
422 "file": "a.py",
423 "line": 7,
424 "signature": "def b()",
425 "doc": "First line.\nSecond.",
426 "bm25_score": 12.5,
427 "ledger_text": "ignored noise",
428 })];
429 let out = brief_search_results(&hits);
430 assert_eq!(out.len(), 1);
431 let o = out[0].as_object().unwrap();
432 assert_eq!(o.get("qname").and_then(Value::as_str), Some("a.b"));
433 assert_eq!(o.get("file").and_then(Value::as_str), Some("a.py:7"));
434 assert_eq!(o.get("signature").and_then(Value::as_str), Some("def b()"));
435 assert_eq!(o.get("doc").and_then(Value::as_str), Some("First line."));
436 assert!(o.get("score").is_some());
437 assert!(o.get("ledger_text").is_none());
438 }
439
440 #[test]
441 fn query_id_is_deterministic_and_starts_with_q() {
442 let a = query_id("read", &["x"]);
443 let b = query_id("read", &["x"]);
444 assert_eq!(a, b);
445 assert!(a.starts_with('Q'));
446 assert_eq!(a.len(), 8);
447 }
448
449 #[test]
452 fn brief_investigate_keeps_entry_points_and_drops_noise() {
453 let full = json!({
454 "query": "playhead",
455 "entry_points": [{"qname":"a.b","file":"a.py","line":3,"signature":"def b()","doc":"hi"}],
456 "ambiguous_terms": ["playhead"],
457 "possible_misses": [{"layer":"app"}],
458 "stale": null,
459 });
460 let v = brief_investigate(&full);
461 let o = v.as_object().unwrap();
462 assert!(
465 !o.contains_key("query"),
466 "input echo `query` dropped by brief"
467 );
468 assert!(o.contains_key("entry_points"));
469 assert!(!o.contains_key("ambiguous_terms"));
470 assert!(!o.contains_key("possible_misses"));
471 assert!(!o.contains_key("stale"), "null stale should be dropped");
472 }
473
474 #[test]
475 fn brief_prepare_change_keeps_load_bearing_drops_orientation() {
476 let full = json!({
477 "description": "migrate tests",
478 "intent": "test",
479 "likely_edit_files": [{
480 "file": "x.py", "why": "matched 'test'", "top_symbol": "a.b",
481 "layer": "tests", "score": 3.5, "hot": false,
482 }],
483 "by_layer": {"tests": []},
484 "recently_touched": [],
485 "scenario_tests": [],
486 "suggested_test_coverage": [],
487 "design_invariants": [{"summary":"i1"}],
488 "known_hazards": [],
489 "safe_change_recipe": {"edit": []},
490 });
491 let v = brief_prepare_change(&full);
492 let o = v.as_object().unwrap();
493 assert!(o.contains_key("likely_edit_files"));
494 assert!(o.contains_key("safe_change_recipe"));
495 assert!(o.contains_key("design_invariants"));
496 assert!(!o.contains_key("by_layer"));
497 assert!(!o.contains_key("recently_touched"));
498 assert!(
499 !o.contains_key("known_hazards"),
500 "empty array should be dropped"
501 );
502 let lef = o["likely_edit_files"].as_array().unwrap();
504 let f0 = lef[0].as_object().unwrap();
505 assert!(f0.contains_key("file"));
506 assert!(f0.contains_key("why"));
507 assert!(f0.contains_key("top_symbol"));
508 assert!(!f0.contains_key("score"));
509 assert!(!f0.contains_key("hot"));
510 }
511
512 #[test]
513 fn brief_context_for_caps_callers_callees_to_top_three() {
514 let full = json!({
515 "symbol": {"qname":"a.b","file":"a.py","line":1,"signature":"def b()","doc":"d"},
516 "callers": [
517 {"qname":"c1","file":"c1.py","line":1},
518 {"qname":"c2","file":"c2.py","line":2},
519 {"qname":"c3","file":"c3.py","line":3},
520 {"qname":"c4","file":"c4.py","line":4},
521 ],
522 "callees": [],
523 "effects": {"declared":[{"effect":"io.fs.read"}]},
524 "ledger": [{"summary":"e1"}, {"summary":"e2"}],
525 });
526 let v = brief_context_for(&full);
527 let o = v.as_object().unwrap();
528 let callers = o["callers"].as_array().unwrap();
529 assert_eq!(callers.len(), 3, "callers should be capped to 3");
530 assert!(
531 !o.contains_key("callees"),
532 "empty callees should be dropped"
533 );
534 assert_eq!(o["ledger_count"].as_u64(), Some(2));
535 assert_eq!(o["effects"].as_array().unwrap().len(), 1);
536 }
537
538 #[test]
539 fn brief_conclusions_list_drops_internal_fields_and_nulls() {
540 let full = json!({
541 "class": "decisions",
542 "symbol": null,
543 "total": 1,
544 "buckets": {
545 "decisions": [{
546 "entry_id": "led_1",
547 "kind": "decision",
548 "qname": "a.b",
549 "symbol_id": "sym_secret",
550 "summary": "ok",
551 "role": null,
552 "command": null,
553 "tags": [],
554 "created_at": "2026-06-02T00:00:00Z",
555 }],
556 },
557 });
558 let v = brief_conclusions_list(&full);
559 let o = v.as_object().unwrap();
560 assert!(!o.contains_key("symbol"), "null symbol should be dropped");
561 let bucket = o["buckets"]["decisions"].as_array().unwrap();
562 let entry = bucket[0].as_object().unwrap();
563 assert!(
564 !entry.contains_key("symbol_id"),
565 "internal symbol_id must be dropped"
566 );
567 assert!(!entry.contains_key("role"), "null role must be dropped");
568 assert!(
569 !entry.contains_key("command"),
570 "null command must be dropped"
571 );
572 assert!(!entry.contains_key("tags"), "empty tags must be dropped");
573 assert_eq!(entry["entry_id"].as_str(), Some("led_1"));
574 assert_eq!(entry["kind"].as_str(), Some("decision"));
575 }
576
577 #[test]
578 fn brief_from_env_reads_env_var() {
579 let prev = std::env::var("ASD_FORMAT").ok();
580 unsafe {
581 std::env::set_var("ASD_FORMAT", "brief");
582 }
583 assert!(brief_from_env());
584 unsafe {
585 std::env::set_var("ASD_FORMAT", "BRIEF");
586 }
587 assert!(brief_from_env());
588 unsafe {
589 std::env::set_var("ASD_FORMAT", "json");
590 }
591 assert!(!brief_from_env());
592 unsafe {
593 std::env::remove_var("ASD_FORMAT");
594 }
595 assert!(!brief_from_env());
596 if let Some(v) = prev {
597 unsafe {
598 std::env::set_var("ASD_FORMAT", v);
599 }
600 }
601 }
602}