1use crate::core::SkillDefinition;
7use crate::search::SearchResultItem;
8use serde_json;
9use std::fmt;
10
11#[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#[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
65pub 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
79fn 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 let mut max_id_width = 2; let mut max_name_width = 4; let mut max_desc_width = 11; let mut max_source_width = 6; let mut max_sim_width = 9; 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 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 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 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
234fn 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
239fn 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
270fn 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
311pub fn escape_xml(input: &str) -> String {
313 input
314 .replace("&", "&")
315 .replace("<", "<")
316 .replace(">", ">")
317 .replace("\"", """)
318 .replace("'", "'")
319}
320
321pub 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
335fn 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
449fn 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
474fn 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
517pub 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
530fn 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
554fn 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
569fn 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}