1use super::RenderOptions;
2use super::ask::format_ask_human;
3use super::obsidian::format_ref;
4use super::recall::format_recall_human;
5use super::search::format_search_human;
6use eyre::Result;
7use std::io::{self, Write};
8use talon_core::{
9 InspectResponse, MetaResponse, ReadResponse, RelatedResponse, SyncResponse, TalonEnvelope,
10 TalonResponseData,
11};
12
13pub(super) fn emit(envelope: &TalonEnvelope) -> Result<()> {
14 let opts = RenderOptions::for_terminal();
15 match envelope.data.as_ref() {
16 Some(TalonResponseData::Search(resp)) => {
17 let warnings = envelope
18 .meta
19 .as_ref()
20 .map_or(&[][..], |m| m.warnings.as_slice());
21 format_search_human(&mut io::stdout(), resp, opts, warnings)?;
22 }
23 Some(TalonResponseData::Ask(resp)) => {
24 let warnings = envelope
25 .meta
26 .as_ref()
27 .map_or(&[][..], |m| m.warnings.as_slice());
28 format_ask_human(&mut io::stdout(), resp, opts, warnings)?;
29 }
30 Some(TalonResponseData::Sync(resp)) => format_sync_human(&mut io::stdout(), resp)?,
31 Some(TalonResponseData::Status(resp)) => {
32 format_status_human(&mut io::stdout(), resp)?;
33 }
34 Some(TalonResponseData::Read(resp)) => emit_read(resp, opts)?,
35 Some(TalonResponseData::Related(resp)) => emit_related(resp, opts)?,
36 Some(TalonResponseData::Meta(resp)) => emit_meta(resp)?,
37 Some(TalonResponseData::Changes(resp)) => emit_changes(resp)?,
38 Some(TalonResponseData::Inspect(resp)) => format_inspect_human(&mut io::stdout(), resp)?,
39 Some(TalonResponseData::Recall(resp)) => {
40 format_recall_human(&mut io::stdout(), resp, opts)?;
41 }
42 None => {
43 if let Some(err) = &envelope.error {
44 writeln!(io::stderr(), "Error [{}]: {}", err.code, err.message)?;
45 }
46 }
47 }
48 Ok(())
49}
50
51pub fn format_sync_human(w: &mut impl Write, resp: &SyncResponse) -> Result<()> {
57 writeln!(
58 w,
59 "Sync: {}{} ({} indexed/updated, {} skipped, {} deleted) in {}ms",
60 if resp.rebuild { "rebuilt " } else { "" },
61 if resp.completed { "OK" } else { "partial" },
62 resp.indexed,
63 resp.skipped,
64 resp.deleted,
65 resp.duration_ms
66 )?;
67 if !resp.fast {
68 let embed_label = if resp.dimension_mismatch {
69 "dimension mismatch"
70 } else if resp.embed_failed > 0 {
71 "partial"
72 } else {
73 "OK"
74 };
75 writeln!(
76 w,
77 "Embed: {embed_label} ({}/{} succeeded, {} failed)",
78 resp.embedded,
79 resp.embedded + resp.embed_failed,
80 resp.embed_failed
81 )?;
82 if let Some(remediation) = resp.embed_remediation.as_deref() {
83 writeln!(w, " ! {remediation}")?;
84 }
85 for line in resp.embed_diagnostics.iter().take(5) {
86 writeln!(w, " - {line}")?;
87 }
88 }
89 Ok(())
90}
91
92pub fn format_status_human(w: &mut impl Write, resp: &talon_core::StatusResponse) -> Result<()> {
98 writeln!(w, "Status: {:?}", resp.state)?;
99 if let Some(reason) = &resp.reason {
100 writeln!(w, " Reason: {reason}")?;
101 }
102 if let Some(vault) = &resp.vault_path {
103 writeln!(w, " Vault: {vault}")?;
104 }
105 if let Some(config) = &resp.config_path {
106 writeln!(w, " Config: {config}")?;
107 }
108 if let Some(db) = &resp.db_path {
109 writeln!(w, " Index: {db}")?;
110 }
111 writeln!(
112 w,
113 " Notes: {} Chunks: {} Failed: {}",
114 resp.index.active_notes, resp.index.chunk_count, resp.index.failed_embeddings,
115 )?;
116 if let Some(dims) = resp.index.vector_dimensions {
117 writeln!(w, " Dimensions: {dims}")?;
118 }
119 Ok(())
120}
121
122pub fn format_inspect_human(w: &mut impl Write, resp: &InspectResponse) -> Result<()> {
132 use anstyle::{AnsiColor, Effects, Style};
133 use talon_core::InspectCheck;
134
135 let opts = super::RenderOptions::for_terminal();
136 let heading = super::style::cs(
137 opts.colors,
138 Style::new().bold().fg_color(Some(AnsiColor::Cyan.into())),
139 );
140 let bold = super::style::cs(opts.colors, Style::new().effects(Effects::BOLD));
141 let dim = super::style::cs(opts.colors, Style::new().effects(Effects::DIMMED));
142
143 let total = resp.findings.len();
144 let finding_word = if total == 1 { "finding" } else { "findings" };
145 writeln!(
146 w,
147 "{heading}Inspect{heading:#} · {bold}{}{bold:#} · {dim}{total} {finding_word}{dim:#}",
148 inspect_label(resp.check)
149 )?;
150
151 if total == 0 {
152 writeln!(w)?;
153 writeln!(w, " {dim}No findings.{dim:#}")?;
154 return Ok(());
155 }
156
157 writeln!(w)?;
158 if resp.check == InspectCheck::All {
159 let mut first_section = true;
160 for check in [
161 InspectCheck::Orphans,
162 InspectCheck::BrokenLinks,
163 InspectCheck::DanglingRefs,
164 InspectCheck::Unreferenced,
165 InspectCheck::Graph,
166 ] {
167 let findings: Vec<_> = resp.findings.iter().filter(|f| f.check == check).collect();
168 if findings.is_empty() {
169 continue;
170 }
171 if !first_section {
172 writeln!(w)?;
173 }
174 first_section = false;
175 writeln!(
176 w,
177 "{bold}{}{bold:#} · {dim}{}{dim:#}",
178 inspect_label(check),
179 findings.len()
180 )?;
181 for (i, f) in findings.iter().enumerate() {
182 format_inspect_card(w, i + 1, f, &bold, &dim)?;
183 }
184 }
185 } else {
186 for (i, f) in resp.findings.iter().enumerate() {
187 format_inspect_card(w, i + 1, f, &bold, &dim)?;
188 }
189 }
190 Ok(())
191}
192
193const fn inspect_label(check: talon_core::InspectCheck) -> &'static str {
194 match check {
195 talon_core::InspectCheck::All => "all",
196 talon_core::InspectCheck::Orphans => "orphans",
197 talon_core::InspectCheck::BrokenLinks => "broken-links",
198 talon_core::InspectCheck::DanglingRefs => "dangling-refs",
199 talon_core::InspectCheck::Unreferenced => "unreferenced",
200 talon_core::InspectCheck::Graph => "graph",
201 }
202}
203
204fn format_inspect_card(
205 w: &mut impl Write,
206 rank: usize,
207 f: &talon_core::InspectFinding,
208 bold: &anstyle::Style,
209 dim: &anstyle::Style,
210) -> Result<()> {
211 let path = f.path.as_str();
212 if let Some(line) = f.line {
213 writeln!(
214 w,
215 " {bold}{rank:>2}{bold:#} {bold}{path}{bold:#}{dim}:{line}{dim:#}"
216 )?;
217 } else {
218 writeln!(w, " {bold}{rank:>2}{bold:#} {bold}{path}{bold:#}")?;
219 }
220 let detail = strip_redundant_prefix(f.check, &f.message);
221 writeln!(w, " {dim}{detail}{dim:#}")?;
222 Ok(())
223}
224
225fn strip_redundant_prefix(check: talon_core::InspectCheck, msg: &str) -> &str {
229 let prefix = match check {
230 talon_core::InspectCheck::BrokenLinks => "broken link: ",
231 talon_core::InspectCheck::DanglingRefs => "dangling ref: ",
232 _ => return msg,
233 };
234 msg.strip_prefix(prefix).unwrap_or(msg)
235}
236
237fn emit_read(resp: &ReadResponse, opts: RenderOptions) -> Result<()> {
238 let vault = resp.vault.as_ref().map(talon_core::ContainerPath::as_str);
239 for result in &resp.results {
240 if !result.found {
241 writeln!(io::stdout(), "Not found: {}", result.vault_path.as_str())?;
242 continue;
243 }
244 let title = result
245 .title
246 .as_deref()
247 .unwrap_or(result.vault_path.as_str());
248 writeln!(io::stdout(), "# {title}")?;
249 let path_ref = format_ref(
250 vault,
251 result.vault_path.as_str(),
252 result.title.as_deref(),
253 result
254 .section
255 .as_ref()
256 .map(|section| section.heading.as_str()),
257 opts.colors,
258 );
259 writeln!(io::stdout(), "Path: {path_ref}")?;
260 if let Some(section) = result.section.as_ref() {
261 writeln!(
262 io::stdout(),
263 "Section: {} (lines {}-{})",
264 section.obsidian_ref,
265 section.from_line,
266 section.to_line
267 )?;
268 }
269 if !result.tags.is_empty() {
270 writeln!(io::stdout(), "Tags: {}", result.tags.join(", "))?;
271 }
272 if !result.links.is_empty() {
273 writeln!(
274 io::stdout(),
275 "Links: {}",
276 format_path_list(vault, &result.links, opts)
277 )?;
278 }
279 if !result.backlinks.is_empty() {
280 writeln!(
281 io::stdout(),
282 "Backlinks: {}",
283 format_path_list(vault, &result.backlinks, opts)
284 )?;
285 }
286 writeln!(io::stdout())?;
287 if let Some(content) = &result.content {
288 writeln!(io::stdout(), "{content}")?;
289 }
290 }
291 Ok(())
292}
293
294fn emit_related(resp: &RelatedResponse, opts: RenderOptions) -> Result<()> {
295 let vault = resp.vault.as_ref().map(talon_core::ContainerPath::as_str);
296 writeln!(
297 io::stdout(),
298 "Related to: {}",
299 format_ref(vault, resp.path.as_str(), None, None, opts.colors)
300 )?;
301 for r in &resp.results {
302 writeln!(
303 io::stdout(),
304 " - {} ({:?})",
305 format_ref(
306 vault,
307 r.vault_path.as_str(),
308 Some(&r.title),
309 None,
310 opts.colors
311 ),
312 r.relation
313 )?;
314 }
315 Ok(())
316}
317
318fn format_path_list(vault: Option<&str>, paths: &[String], opts: RenderOptions) -> String {
319 paths
320 .iter()
321 .map(|path| format_ref(vault, path, None, None, opts.colors))
322 .collect::<Vec<_>>()
323 .join(", ")
324}
325
326fn emit_meta(resp: &MetaResponse) -> Result<()> {
327 writeln!(io::stdout(), "Frontmatter: {} entries", resp.entries.len())?;
328 if let Some(counts) = &resp.tag_counts {
329 writeln!(io::stdout(), "Tags: {}", counts.len())?;
330 for (tag, count) in counts.iter().take(10) {
331 writeln!(io::stdout(), " {tag}: {count}")?;
332 }
333 }
334 for e in resp.entries.iter().take(10) {
335 writeln!(io::stdout(), " - {}", e.path.as_str())?;
336 }
337 Ok(())
338}
339
340fn emit_changes(resp: &talon_core::ChangesResponse) -> Result<()> {
341 writeln!(
342 io::stdout(),
343 "Changes: {} added, {} modified, {} deleted",
344 resp.added.len(),
345 resp.modified.len(),
346 resp.deleted.len()
347 )?;
348 Ok(())
349}