Skip to main content

spool/output/
markdown.rs

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}