1use crate::domain::{ContextBundle, TargetTool};
2
3pub fn render(bundle: &ContextBundle, max_chars: usize) -> String {
4 let mut output = String::new();
5 output.push_str("# spool context\n\n");
6 output.push_str(&format!("- task: {}\n", bundle.input.task));
7 output.push_str(&format!("- cwd: {}\n", bundle.input.cwd.display()));
8 if let Some(project) = &bundle.route.project {
9 output.push_str(&format!("- project: {} ({})\n", project.name, project.id));
10 }
11 output.push('\n');
12 if !bundle.route.lifecycle_candidates.is_empty() {
13 output.push_str("## 记忆(accepted / canonical)\n\n");
14 for candidate in &bundle.route.lifecycle_candidates {
15 let summary = truncate_chars(&candidate.summary, 120);
16 output.push_str(&format!(
17 "- [{}] [{}] **{}** — {}\n",
18 candidate.score, candidate.memory_type, candidate.title, summary
19 ));
20 }
21 output.push('\n');
22 }
23 for candidate in &bundle.route.candidates {
24 output.push_str(&format!("## {}\n\n", candidate.relative_path));
25 output.push_str(&format!("- score: {}\n", candidate.score));
26 output.push_str(&format!("- reasons: {}\n\n", candidate.reasons.join("; ")));
27 output.push_str(&candidate.excerpt);
28 output.push_str("\n\n");
29 }
30 if output.chars().count() > max_chars {
31 output.chars().take(max_chars).collect()
32 } else {
33 output
34 }
35}
36
37fn truncate_chars(value: &str, max_chars: usize) -> String {
38 let chars: Vec<char> = value.chars().collect();
39 if chars.len() <= max_chars {
40 return value.to_string();
41 }
42 let mut out: String = chars.iter().take(max_chars).collect();
43 out.push('…');
44 out
45}
46
47pub fn render_explain(bundle: &ContextBundle) -> String {
48 let mut output = String::new();
49 output.push_str("# route explain\n\n");
50 output.push_str(&format!(
51 "- target: {}\n- cwd: {}\n- matched_project_id: {}\n- note_roots: {}\n- scan_roots: {}\n- limits: max_files={}, max_file_bytes={}, max_total_bytes={}, max_depth={}\n- note_count: {}\n\n",
52 match bundle.input.target {
53 TargetTool::Claude => "claude",
54 TargetTool::Codex => "codex",
55 TargetTool::Opencode => "opencode",
56 },
57 bundle.input.cwd.display(),
58 bundle.route.debug.matched_project_id.as_deref().unwrap_or("none"),
59 if bundle.route.debug.note_roots.is_empty() { "none".to_string() } else { bundle.route.debug.note_roots.join(", ") },
60 if bundle.route.debug.scan_roots.is_empty() { "none".to_string() } else { bundle.route.debug.scan_roots.join(", ") },
61 bundle.route.debug.limits.max_files,
62 bundle.route.debug.limits.max_file_bytes,
63 bundle.route.debug.limits.max_total_bytes,
64 bundle.route.debug.limits.max_depth,
65 bundle.route.debug.note_count,
66 ));
67 match &bundle.route.project {
68 Some(project) => {
69 output.push_str(&format!(
70 "## project\n- id: {}\n- reason: {}\n\n",
71 project.id, project.reason
72 ));
73 }
74 None => output.push_str("## project\n- none\n\n"),
75 }
76 output.push_str("## modules\n");
77 if bundle.route.modules.is_empty() {
78 output.push_str("- none\n");
79 } else {
80 for module in &bundle.route.modules {
81 output.push_str(&format!("- {}: {}\n", module.id, module.reasons.join("; ")));
82 }
83 }
84 output.push_str("\n## scenes\n");
85 if bundle.route.scenes.is_empty() {
86 output.push_str("- none\n");
87 } else {
88 for scene in &bundle.route.scenes {
89 let preferred_notes = if scene.preferred_notes.is_empty() {
90 "none".to_string()
91 } else {
92 scene.preferred_notes.join(", ")
93 };
94 output.push_str(&format!(
95 "- {}: {} | preferred_notes: {}\n",
96 scene.id,
97 scene.reasons.join("; "),
98 preferred_notes
99 ));
100 }
101 }
102 output.push_str("\n## candidates\n");
103 if bundle.route.candidates.is_empty() {
104 output.push_str("- none\n");
105 } else {
106 for candidate in &bundle.route.candidates {
107 output.push_str(&format!(
108 "- {} [{}] (confidence={}): {}\n",
109 candidate.relative_path,
110 candidate.score,
111 match candidate.confidence {
112 crate::domain::ConfidenceTier::High => "high",
113 crate::domain::ConfidenceTier::Medium => "medium",
114 crate::domain::ConfidenceTier::Low => "low",
115 },
116 candidate.reasons.join("; ")
117 ));
118 if !candidate.score_breakdown.is_empty() {
119 for contrib in &candidate.score_breakdown {
120 output.push_str(&format!(
121 " - {:?} {}/{} {:+}\n",
122 contrib.source, contrib.field, contrib.term, contrib.weight
123 ));
124 }
125 }
126 }
127 }
128 output.push_str("\n## lifecycle_candidates\n");
129 if bundle.route.lifecycle_candidates.is_empty() {
130 output.push_str("- none\n");
131 } else {
132 for candidate in &bundle.route.lifecycle_candidates {
133 output.push_str(&format!(
134 "- {} [{}] ({}): {}\n",
135 candidate.title,
136 candidate.score,
137 candidate.memory_type,
138 candidate.reasons.join("; ")
139 ));
140 }
141 }
142 output
143}