Skip to main content

fastskill_core/output/
mod.rs

1//! Output formatting module providing consistent formatting across commands
2//!
3//! This module provides shared output formatting capabilities that can be used
4//! by multiple CLI commands to ensure consistent output styling.
5
6use crate::core::SkillDefinition;
7use crate::search::SearchResultItem;
8use serde_json;
9use std::fmt;
10
11/// One row for the list table: union of all skills with presence and gap flags.
12#[derive(Debug, Clone, serde::Serialize)]
13pub struct ListRow {
14    pub id: String,
15    pub name: String,
16    pub description: String,
17    pub version: Option<String>,
18    pub in_manifest: bool,
19    pub in_lock: bool,
20    pub installed: bool,
21    pub source_path: Option<String>,
22    pub source_type: Option<String>,
23    pub missing_from_folder: bool,
24    pub missing_from_lock: bool,
25    pub missing_from_manifest: bool,
26}
27
28/// Supported output formats
29#[derive(Debug, Clone, PartialEq)]
30pub enum OutputFormat {
31    Table,
32    Json,
33    Grid,
34    Xml,
35}
36
37impl fmt::Display for OutputFormat {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        match self {
40            OutputFormat::Table => write!(f, "table"),
41            OutputFormat::Json => write!(f, "json"),
42            OutputFormat::Grid => write!(f, "grid"),
43            OutputFormat::Xml => write!(f, "xml"),
44        }
45    }
46}
47
48impl std::str::FromStr for OutputFormat {
49    type Err = String;
50
51    fn from_str(s: &str) -> Result<Self, Self::Err> {
52        match s.to_lowercase().as_str() {
53            "table" => Ok(OutputFormat::Table),
54            "json" => Ok(OutputFormat::Json),
55            "grid" => Ok(OutputFormat::Grid),
56            "xml" => Ok(OutputFormat::Xml),
57            _ => Err(format!(
58                "Invalid format '{}'. Supported formats: table, json, grid, xml",
59                s
60            )),
61        }
62    }
63}
64
65/// Format search results in the specified output format
66pub fn format_search_results(
67    results: &[SearchResultItem],
68    format: OutputFormat,
69    query: &str,
70) -> Result<String, String> {
71    match format {
72        OutputFormat::Table => format_search_results_as_table(results, query),
73        OutputFormat::Json => format_search_results_as_json(results),
74        OutputFormat::Grid => format_search_results_as_grid(results, query),
75        OutputFormat::Xml => format_search_results_as_xml(results),
76    }
77}
78
79/// Format search results as ASCII table
80fn format_search_results_as_table(
81    results: &[SearchResultItem],
82    query: &str,
83) -> Result<String, String> {
84    if results.is_empty() {
85        return Ok(format!("No skills found matching '{}'", query));
86    }
87
88    let mut output = String::new();
89
90    output.push_str(&format!(
91        "Found {} skills matching '{}':\n\n",
92        results.len(),
93        query
94    ));
95
96    // Determine column widths
97    let mut max_id_width = 2; // "ID"
98    let mut max_name_width = 4; // "Name"
99    let mut max_desc_width = 11; // "Description"
100    let mut max_source_width = 6; // "Source"
101    let mut max_sim_width = 9; // "Similarity"
102
103    for item in results {
104        max_id_width = max_id_width.max(item.id.len());
105        max_name_width = max_name_width.max(item.name.len());
106        max_desc_width = max_desc_width.max(
107            item.description
108                .as_deref()
109                .unwrap_or("No description")
110                .len()
111                .min(50),
112        );
113        max_source_width = max_source_width.max(item.source.len());
114        if let Some(sim) = item.similarity {
115            let sim_str = format!("{:.3}", sim);
116            max_sim_width = max_sim_width.max(sim_str.len());
117        }
118    }
119
120    // Create table header
121    let header = if results.iter().any(|r| r.similarity.is_some()) {
122        format!(
123            "+-{}-+-{}-+-{}-+-{}-+-{}-+\n| {:<width_id$} | {:<width_name$} | {:<width_desc$} | {:<width_source$} | {:<width_sim$} |\n+-{}-+-{}-+-{}-+-{}-+-{}-+",
124            "-".repeat(max_id_width),
125            "-".repeat(max_name_width),
126            "-".repeat(max_desc_width),
127            "-".repeat(max_source_width),
128            "-".repeat(max_sim_width),
129            "ID",
130            "Name",
131            "Description",
132            "Source",
133            "Similarity",
134            "-".repeat(max_id_width),
135            "-".repeat(max_name_width),
136            "-".repeat(max_desc_width),
137            "-".repeat(max_source_width),
138            "-".repeat(max_sim_width),
139            width_id = max_id_width,
140            width_name = max_name_width,
141            width_desc = max_desc_width,
142            width_source = max_source_width,
143            width_sim = max_sim_width
144        )
145    } else {
146        format!(
147            "+-{}-+-{}-+-{}-+-{}-+\n| {:<width_id$} | {:<width_name$} | {:<width_desc$} | {:<width_source$} |\n+-{}-+-{}-+-{}-+-{}-+",
148            "-".repeat(max_id_width),
149            "-".repeat(max_name_width),
150            "-".repeat(max_desc_width),
151            "-".repeat(max_source_width),
152            "ID",
153            "Name",
154            "Description",
155            "Source",
156            "-".repeat(max_id_width),
157            "-".repeat(max_name_width),
158            "-".repeat(max_desc_width),
159            "-".repeat(max_source_width),
160            width_id = max_id_width,
161            width_name = max_name_width,
162            width_desc = max_desc_width,
163            width_source = max_source_width
164        )
165    };
166
167    output.push_str(&header);
168    output.push('\n');
169
170    // Add rows
171    for item in results {
172        let desc = item.description.as_deref().unwrap_or("No description");
173        let desc_str = if desc.len() > 50 {
174            format!("{}...", &desc[..47])
175        } else {
176            desc.to_string()
177        };
178
179        let row = if let Some(sim) = item.similarity {
180            format!(
181                "| {:<width_id$} | {:<width_name$} | {:<width_desc$} | {:<width_source$} | {:<width_sim$} |",
182                item.id,
183                item.name,
184                desc_str,
185                item.source,
186                format!("{:.3}", sim),
187                width_id = max_id_width,
188                width_name = max_name_width,
189                width_desc = max_desc_width,
190                width_source = max_source_width,
191                width_sim = max_sim_width
192            )
193        } else {
194            format!(
195                "| {:<width_id$} | {:<width_name$} | {:<width_desc$} | {:<width_source$} |",
196                item.id,
197                item.name,
198                desc_str,
199                item.source,
200                width_id = max_id_width,
201                width_name = max_name_width,
202                width_desc = max_desc_width,
203                width_source = max_source_width
204            )
205        };
206        output.push_str(&row);
207        output.push('\n');
208    }
209
210    // Add bottom border
211    let footer = if results.iter().any(|r| r.similarity.is_some()) {
212        format!(
213            "+-{}-+-{}-+-{}-+-{}-+-{}-+",
214            "-".repeat(max_id_width),
215            "-".repeat(max_name_width),
216            "-".repeat(max_desc_width),
217            "-".repeat(max_source_width),
218            "-".repeat(max_sim_width)
219        )
220    } else {
221        format!(
222            "+-{}-+-{}-+-{}-+-{}-+",
223            "-".repeat(max_id_width),
224            "-".repeat(max_name_width),
225            "-".repeat(max_desc_width),
226            "-".repeat(max_source_width)
227        )
228    };
229    output.push_str(&footer);
230
231    Ok(output)
232}
233
234/// Format search results as JSON
235fn format_search_results_as_json(results: &[SearchResultItem]) -> Result<String, String> {
236    serde_json::to_string_pretty(results).map_err(|e| format!("Failed to serialize to JSON: {}", e))
237}
238
239/// Format search results as grid (simple table format)
240fn format_search_results_as_grid(
241    results: &[SearchResultItem],
242    query: &str,
243) -> Result<String, String> {
244    if results.is_empty() {
245        return Ok(format!("No skills found matching '{}'", query));
246    }
247
248    let mut output = String::new();
249    output.push_str(&format!(
250        "Found {} skills matching '{}':\n\n",
251        results.len(),
252        query
253    ));
254
255    for item in results {
256        output.push_str(&format!("  - {}", item.name));
257        if let Some(desc) = &item.description {
258            output.push_str(&format!(": {}", desc));
259        }
260        output.push_str(&format!(" ({})", item.source));
261        if let Some(sim) = item.similarity {
262            output.push_str(&format!(" [{:.3}]", sim));
263        }
264        output.push('\n');
265    }
266
267    Ok(output)
268}
269
270/// Format search results as XML
271fn format_search_results_as_xml(results: &[SearchResultItem]) -> Result<String, String> {
272    let mut xml = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<skills>\n");
273
274    for item in results {
275        xml.push_str(&format!(
276            "  <skill id=\"{}\" source=\"{}\">\n",
277            escape_xml(&item.id),
278            escape_xml(&item.source)
279        ));
280        xml.push_str(&format!("    <name>{}</name>\n", escape_xml(&item.name)));
281
282        if let Some(description) = &item.description {
283            xml.push_str(&format!(
284                "    <description>{}</description>\n",
285                escape_xml(description)
286            ));
287        }
288
289        if let Some(similarity) = item.similarity {
290            xml.push_str(&format!("    <similarity>{:.3}</similarity>\n", similarity));
291        }
292
293        if let Some(path) = &item.path {
294            xml.push_str(&format!("    <path>{}</path>\n", escape_xml(path)));
295        }
296
297        if let Some(repository) = &item.repository {
298            xml.push_str(&format!(
299                "    <repository>{}</repository>\n",
300                escape_xml(repository)
301            ));
302        }
303
304        xml.push_str("  </skill>\n");
305    }
306
307    xml.push_str("</skills>\n");
308    Ok(xml)
309}
310
311/// Escape XML special characters
312pub fn escape_xml(input: &str) -> String {
313    input
314        .replace("&", "&amp;")
315        .replace("<", "&lt;")
316        .replace(">", "&gt;")
317        .replace("\"", "&quot;")
318        .replace("'", "&apos;")
319}
320
321/// Format list results in the specified output format
322pub fn format_list_results(
323    rows: &[ListRow],
324    format: OutputFormat,
325    details: bool,
326) -> Result<String, String> {
327    match format {
328        OutputFormat::Table => format_list_table(rows, details),
329        OutputFormat::Json => serde_json::to_string_pretty(rows).map_err(|e| e.to_string()),
330        OutputFormat::Grid => format_list_grid(rows, details),
331        OutputFormat::Xml => format_list_xml(rows),
332    }
333}
334
335/// Format list results as table
336fn format_list_table(rows: &[ListRow], details: bool) -> Result<String, String> {
337    if rows.is_empty() {
338        return Ok("No skills found.".to_string());
339    }
340
341    let mut output = String::new();
342    if details {
343        let headers = [
344            "ID",
345            "Name",
346            "Description",
347            "Version",
348            "Manifest",
349            "Lock",
350            "Installed",
351            "Source Path",
352            "Type",
353            "Flags",
354        ];
355        let mut col_widths = vec![0; headers.len()];
356        for (i, h) in headers.iter().enumerate() {
357            col_widths[i] = h.len();
358        }
359        for row in rows {
360            col_widths[0] = col_widths[0].max(row.id.len());
361            col_widths[1] = col_widths[1].max(row.name.len());
362            col_widths[2] = col_widths[2].max(row.description.len());
363            col_widths[3] = col_widths[3].max(row.version.as_deref().unwrap_or("-").len());
364            col_widths[4] = col_widths[4].max(1);
365            col_widths[5] = col_widths[5].max(1);
366            col_widths[6] = col_widths[6].max(1);
367            col_widths[7] = col_widths[7].max(row.source_path.as_deref().unwrap_or("-").len());
368            col_widths[8] = col_widths[8].max(row.source_type.as_deref().unwrap_or("-").len());
369            let flags = build_list_flags_str(row);
370            col_widths[9] = col_widths[9].max(flags.len());
371        }
372
373        let header_row: Vec<String> = headers
374            .iter()
375            .enumerate()
376            .map(|(i, h)| format!("{:width$}", *h, width = col_widths[i]))
377            .collect();
378        output.push('\n');
379        output.push_str(&header_row.join("  "));
380        output.push('\n');
381        output.push_str(&"-".repeat(header_row.join("  ").len()));
382        output.push('\n');
383
384        for row in rows {
385            let version = row.version.as_deref().unwrap_or("-");
386            let in_manifest = if row.in_manifest { "Y" } else { "-" };
387            let in_lock = if row.in_lock { "Y" } else { "-" };
388            let installed = if row.installed { "Y" } else { "-" };
389            let source_path = row.source_path.as_deref().unwrap_or("-");
390            let source_type = row.source_type.as_deref().unwrap_or("-");
391            let flags = build_list_flags_str(row);
392            let line = [
393                format!("{:width$}", row.id, width = col_widths[0]),
394                format!("{:width$}", row.name, width = col_widths[1]),
395                format!("{:width$}", row.description, width = col_widths[2]),
396                format!("{:width$}", version, width = col_widths[3]),
397                format!("{:width$}", in_manifest, width = col_widths[4]),
398                format!("{:width$}", in_lock, width = col_widths[5]),
399                format!("{:width$}", installed, width = col_widths[6]),
400                format!("{:width$}", source_path, width = col_widths[7]),
401                format!("{:width$}", source_type, width = col_widths[8]),
402                format!("{:width$}", flags, width = col_widths[9]),
403            ];
404            output.push_str(&line.join("  "));
405            output.push('\n');
406        }
407    } else {
408        let headers = ["ID", "Name", "Description", "Flags"];
409        let mut col_widths = vec![0; headers.len()];
410        for (i, h) in headers.iter().enumerate() {
411            col_widths[i] = h.len();
412        }
413        for row in rows {
414            col_widths[0] = col_widths[0].max(row.id.len());
415            col_widths[1] = col_widths[1].max(row.name.len());
416            col_widths[2] = col_widths[2].max(row.description.len());
417            let flags = build_list_flags_str(row);
418            col_widths[3] = col_widths[3].max(flags.len());
419        }
420
421        let header_row: Vec<String> = headers
422            .iter()
423            .enumerate()
424            .map(|(i, h)| format!("{:width$}", *h, width = col_widths[i]))
425            .collect();
426        output.push('\n');
427        output.push_str(&header_row.join("  "));
428        output.push('\n');
429        output.push_str(&"-".repeat(header_row.join("  ").len()));
430        output.push('\n');
431
432        for row in rows {
433            let flags = build_list_flags_str(row);
434            let line = [
435                format!("{:width$}", row.id, width = col_widths[0]),
436                format!("{:width$}", row.name, width = col_widths[1]),
437                format!("{:width$}", row.description, width = col_widths[2]),
438                format!("{:width$}", flags, width = col_widths[3]),
439            ];
440            output.push_str(&line.join("  "));
441            output.push('\n');
442        }
443    }
444
445    output.push('\n');
446    Ok(output)
447}
448
449/// Format list results as grid (simple list format)
450fn format_list_grid(rows: &[ListRow], _details: bool) -> Result<String, String> {
451    if rows.is_empty() {
452        return Ok("No skills found.".to_string());
453    }
454
455    let mut output = String::new();
456    for row in rows {
457        output.push_str(&format!(
458            "  - {} (v{})",
459            row.name,
460            row.version.as_deref().unwrap_or("unknown")
461        ));
462        if row.source_type.is_some() || row.source_path.is_some() {
463            output.push_str(&format!(
464                " [{}]",
465                row.source_type.as_deref().unwrap_or("unknown")
466            ));
467        }
468        output.push('\n');
469    }
470
471    Ok(output)
472}
473
474/// Format list results as XML
475fn format_list_xml(rows: &[ListRow]) -> Result<String, String> {
476    let mut xml = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<skills>\n");
477
478    for row in rows {
479        xml.push_str(&format!("  <skill id=\"{}\">\n", escape_xml(&row.id)));
480        xml.push_str(&format!("    <name>{}</name>\n", escape_xml(&row.name)));
481        xml.push_str(&format!(
482            "    <description>{}</description>\n",
483            escape_xml(&row.description)
484        ));
485        if let Some(version) = &row.version {
486            xml.push_str(&format!("    <version>{}</version>\n", escape_xml(version)));
487        }
488        xml.push_str(&format!(
489            "    <in_manifest>{}</in_manifest>\n",
490            row.in_manifest
491        ));
492        xml.push_str(&format!("    <in_lock>{}</in_lock>\n", row.in_lock));
493        xml.push_str(&format!("    <installed>{}</installed>\n", row.installed));
494        if let Some(source_path) = &row.source_path {
495            xml.push_str(&format!(
496                "    <source_path>{}</source_path>\n",
497                escape_xml(source_path)
498            ));
499        }
500        if let Some(source_type) = &row.source_type {
501            xml.push_str(&format!(
502                "    <source_type>{}</source_type>\n",
503                escape_xml(source_type)
504            ));
505        }
506        let flags = build_list_flags_str(row);
507        if flags != "-" {
508            xml.push_str(&format!("    <flags>{}</flags>\n", escape_xml(&flags)));
509        }
510        xml.push_str("  </skill>\n");
511    }
512
513    xml.push_str("</skills>\n");
514    Ok(xml)
515}
516
517/// Format show results in the specified output format
518pub fn format_show_results(
519    skills: &[SkillDefinition],
520    format: OutputFormat,
521) -> Result<String, String> {
522    match format {
523        OutputFormat::Table => format_show_table(skills),
524        OutputFormat::Json => serde_json::to_string_pretty(skills).map_err(|e| e.to_string()),
525        OutputFormat::Grid => format_show_grid(skills),
526        OutputFormat::Xml => format_show_xml(skills),
527    }
528}
529
530/// Format show results as table
531fn format_show_table(skills: &[SkillDefinition]) -> Result<String, String> {
532    if skills.is_empty() {
533        return Ok("No skills found.".to_string());
534    }
535
536    let mut output = String::new();
537    for skill in skills {
538        output.push_str(&format!("Skill: {}\n", skill.name));
539        output.push_str(&format!("  ID: {}\n", skill.id));
540        output.push_str(&format!("  Version: {}\n", skill.version));
541        output.push_str(&format!("  Description: {}\n", skill.description));
542        if let Some(source_type) = &skill.source_type {
543            output.push_str(&format!("  Source Type: {:?}\n", source_type));
544        }
545        if let Some(source_url) = &skill.source_url {
546            output.push_str(&format!("  Source URL: {}\n", source_url));
547        }
548        output.push('\n');
549    }
550
551    Ok(output)
552}
553
554/// Format show results as grid (simple list format)
555fn format_show_grid(skills: &[SkillDefinition]) -> Result<String, String> {
556    if skills.is_empty() {
557        return Ok("No skills found.".to_string());
558    }
559
560    let mut output = String::new();
561    output.push_str(&format!("Installed Skills ({}):\n", skills.len()));
562    for skill in skills {
563        output.push_str(&format!("  • {} (v{})\n", skill.name, skill.version));
564    }
565
566    Ok(output)
567}
568
569/// Format show results as XML
570fn format_show_xml(skills: &[SkillDefinition]) -> Result<String, String> {
571    let mut xml = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<skills>\n");
572
573    for skill in skills {
574        xml.push_str(&format!(
575            "  <skill id=\"{}\">\n",
576            escape_xml(skill.id.as_ref())
577        ));
578        xml.push_str(&format!("    <name>{}</name>\n", escape_xml(&skill.name)));
579        xml.push_str(&format!(
580            "    <version>{}</version>\n",
581            escape_xml(&skill.version)
582        ));
583        xml.push_str(&format!(
584            "    <description>{}</description>\n",
585            escape_xml(&skill.description)
586        ));
587        if let Some(source_type) = &skill.source_type {
588            xml.push_str(&format!(
589                "    <source_type>{:?}</source_type>\n",
590                source_type
591            ));
592        }
593        if let Some(source_url) = &skill.source_url {
594            xml.push_str(&format!(
595                "    <source_url>{}</source_url>\n",
596                escape_xml(source_url)
597            ));
598        }
599        xml.push_str("  </skill>\n");
600    }
601
602    xml.push_str("</skills>\n");
603    Ok(xml)
604}
605
606fn build_list_flags_str(row: &ListRow) -> String {
607    let mut parts = Vec::new();
608    if row.missing_from_folder {
609        parts.push("missing from folder");
610    }
611    if row.missing_from_lock {
612        parts.push("missing from lock");
613    }
614    if row.missing_from_manifest {
615        parts.push("missing from manifest");
616    }
617    if parts.is_empty() {
618        "-".to_string()
619    } else {
620        parts.join("; ")
621    }
622}