Skip to main content

talon_cli/output/
human.rs

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
51/// Formats a sync response for human reading.
52///
53/// # Errors
54///
55/// Returns an error if writing to `w` fails.
56pub 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
92/// Formats a status response for human reading.
93///
94/// # Errors
95///
96/// Returns an error if writing to `w` fails.
97pub 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
122/// Formats an inspect response for human reading.
123///
124/// Mirrors `format_search_human`'s card style: a styled headline summarising
125/// the run, then per-check sections with numbered findings (rank + path on
126/// one line, indented detail beneath).
127///
128/// # Errors
129///
130/// Returns an error if writing to `w` fails.
131pub 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
225/// Drops the leading `"<check>: "` prefix from a finding message when the
226/// section header already conveys it. Keeps the prefix for `--all` callers
227/// who consume the message without the section context.
228fn 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}