Skip to main content

ploit_malper/frontend/
mod.rs

1pub mod assets;
2pub mod diff;
3pub mod findings;
4pub mod history;
5pub mod runs;
6pub mod services;
7
8use std::collections::{HashMap, HashSet};
9use std::io::{self, IsTerminal};
10
11use serde_json::Value;
12use time::OffsetDateTime;
13
14use crate::config::ConfigManager;
15use crate::db::models::{Asset, Finding, Observation, Service};
16use crate::db::Storage;
17use crate::error::{AppError, Result};
18
19// ---------------------------------------------------------------------------
20// Command-line argument parsing
21// ---------------------------------------------------------------------------
22
23const BOOLEAN_FLAGS: &[&str] = &["verbose", "json", "new", "changed", "fixed", "help"];
24const VALUE_FLAGS: &[&str] = &[
25    "target",
26    "since",
27    "port",
28    "product",
29    "asset",
30    "severity",
31    "cve",
32    "backend",
33    "pocketbase-url",
34    "sqlite-path",
35];
36
37/// Minimal, consistent flag parser shared by all frontend commands.
38#[derive(Debug, Default, Clone)]
39pub struct Args {
40    flags: HashSet<String>,
41    values: HashMap<String, String>,
42    positionals: Vec<String>,
43}
44
45impl Args {
46    pub fn parse(args: &[String]) -> Result<Self> {
47        let mut flags = HashSet::new();
48        let mut values = HashMap::new();
49        let mut positionals = Vec::new();
50
51        let mut i = 0usize;
52        while i < args.len() {
53            let arg = &args[i];
54            if let Some(rest) = arg.strip_prefix("--") {
55                if let Some((key, value)) = rest.split_once('=') {
56                    if VALUE_FLAGS.contains(&key) {
57                        values.insert(key.to_string(), value.to_string());
58                        i += 1;
59                        continue;
60                    }
61                    return Err(AppError::Message(format!(
62                        "unknown option: --{} (use --{} VALUE)",
63                        key, key
64                    )));
65                }
66                if VALUE_FLAGS.contains(&rest) {
67                    let value = args.get(i + 1).ok_or_else(|| {
68                        AppError::Message(format!("option --{} requires a value", rest))
69                    })?;
70                    values.insert(rest.to_string(), value.clone());
71                    i += 2;
72                    continue;
73                }
74                if BOOLEAN_FLAGS.contains(&rest) {
75                    flags.insert(rest.to_string());
76                    i += 1;
77                    continue;
78                }
79                return Err(AppError::Message(format!("unknown option: --{}", rest)));
80            } else if arg.starts_with('-') && arg.len() > 1 {
81                let short = &arg[1..];
82                match short {
83                    "v" => {
84                        flags.insert("verbose".to_string());
85                    }
86                    "h" => {
87                        flags.insert("help".to_string());
88                    }
89                    _ => {
90                        return Err(AppError::Message(format!("unknown option: -{}", short)));
91                    }
92                }
93                i += 1;
94                continue;
95            }
96            positionals.push(arg.clone());
97            i += 1;
98        }
99
100        Ok(Self {
101            flags,
102            values,
103            positionals,
104        })
105    }
106
107    pub fn has(&self, name: &str) -> bool {
108        self.flags.contains(name)
109    }
110
111    pub fn get(&self, name: &str) -> Option<&str> {
112        self.values.get(name).map(|s| s.as_str())
113    }
114
115    pub fn positionals(&self) -> &[String] {
116        &self.positionals
117    }
118}
119
120// ---------------------------------------------------------------------------
121// Colour helpers (terminal friendly, honour NO_COLOR / non-tty output)
122// ---------------------------------------------------------------------------
123
124pub fn color_enabled() -> bool {
125    if std::env::var_os("NO_COLOR").is_some() {
126        return false;
127    }
128    io::stdout().is_terminal()
129}
130
131fn paint(text: &str, code: &str) -> String {
132    if color_enabled() {
133        format!("\x1b[{}m{}\x1b[0m", code, text)
134    } else {
135        text.to_string()
136    }
137}
138
139pub fn red(text: &str) -> String {
140    paint(text, "31")
141}
142
143pub fn green(text: &str) -> String {
144    paint(text, "32")
145}
146
147pub fn yellow(text: &str) -> String {
148    paint(text, "33")
149}
150
151pub fn cyan(text: &str) -> String {
152    paint(text, "36")
153}
154
155pub fn dim(text: &str) -> String {
156    paint(text, "2")
157}
158
159pub fn bold(text: &str) -> String {
160    paint(text, "1")
161}
162
163pub fn severity_style(severity: &str) -> &'static str {
164    match severity.to_lowercase().as_str() {
165        "critical" => "1;31",
166        "high" => "31",
167        "medium" => "33",
168        "low" => "34",
169        "info" | "none" => "2",
170        _ => "0",
171    }
172}
173
174pub fn severity_label(severity: &str) -> String {
175    severity.to_uppercase()
176}
177
178/// Rank used to sort severities from most to least severe.
179pub fn severity_rank(severity: &str) -> u8 {
180    match severity.to_lowercase().as_str() {
181        "critical" => 0,
182        "high" => 1,
183        "medium" => 2,
184        "low" => 3,
185        "info" => 4,
186        "none" => 5,
187        _ => 9,
188    }
189}
190
191// ---------------------------------------------------------------------------
192// Table rendering
193// ---------------------------------------------------------------------------
194
195pub struct Cell {
196    pub text: String,
197    pub style: Option<&'static str>,
198}
199
200impl Cell {
201    pub fn plain(text: String) -> Self {
202        Self { text, style: None }
203    }
204
205    pub fn styled(text: String, style: &'static str) -> Self {
206        Self {
207            text,
208            style: Some(style),
209        }
210    }
211}
212
213pub struct Table {
214    headers: Vec<String>,
215    rows: Vec<Vec<Cell>>,
216    max_width: usize,
217}
218
219impl Table {
220    pub fn new(headers: Vec<String>) -> Self {
221        Self {
222            headers,
223            rows: Vec::new(),
224            max_width: 60,
225        }
226    }
227
228    pub fn add_row(&mut self, cells: Vec<Cell>) {
229        self.rows.push(cells);
230    }
231
232    pub fn render(&self) -> String {
233        let mut widths: Vec<usize> = self.headers.iter().map(|h| h.chars().count()).collect();
234        for row in &self.rows {
235            for (i, cell) in row.iter().enumerate() {
236                if i < widths.len() {
237                    widths[i] = widths[i].max(cell.text.chars().count());
238                }
239            }
240        }
241        for w in widths.iter_mut() {
242            *w = (*w).min(self.max_width);
243        }
244
245        let mut out = String::new();
246        let header_cells: Vec<String> = self
247            .headers
248            .iter()
249            .enumerate()
250            .map(|(i, h)| pad(&truncate(h, widths[i]), widths[i]))
251            .collect();
252        out.push_str(&header_cells.join("  "));
253        out.push('\n');
254
255        out.push_str(
256            &widths
257                .iter()
258                .map(|w| "-".repeat(*w))
259                .collect::<Vec<_>>()
260                .join("  "),
261        );
262        out.push('\n');
263
264        for row in &self.rows {
265            let mut line = String::new();
266            for (i, cell) in row.iter().enumerate() {
267                if i > 0 {
268                    line.push_str("  ");
269                }
270                let width = widths.get(i).copied().unwrap_or(self.max_width);
271                let padded = pad(&truncate(&cell.text, width), width);
272                line.push_str(&match cell.style {
273                    Some(code) => paint(&padded, code),
274                    None => padded,
275                });
276            }
277            out.push_str(&line);
278            out.push('\n');
279        }
280        out
281    }
282}
283
284fn pad(text: &str, width: usize) -> String {
285    let len = text.chars().count();
286    if len >= width {
287        return text.to_string();
288    }
289    let mut out = text.to_string();
290    out.extend(std::iter::repeat_n(' ', width - len));
291    out
292}
293
294pub fn truncate(value: &str, width: usize) -> String {
295    let count = value.chars().count();
296    if count <= width {
297        return value.to_string();
298    }
299    if width <= 1 {
300        return "…".to_string();
301    }
302    let mut out: String = value.chars().take(width - 1).collect();
303    out.push('…');
304    out
305}
306
307// ---------------------------------------------------------------------------
308// Timestamp helpers
309// ---------------------------------------------------------------------------
310
311fn parse_date_only(s: &str) -> Option<OffsetDateTime> {
312    let mut parts = s.split('-');
313    let year: i32 = parts.next()?.parse().ok()?;
314    let month: u8 = parts.next()?.parse().ok()?;
315    let day: u8 = parts.next()?.parse().ok()?;
316    if parts.next().is_some() {
317        return None;
318    }
319    let date =
320        time::Date::from_calendar_date(year, time::Month::try_from(month).ok()?, day).ok()?;
321    Some(date.midnight().assume_utc())
322}
323
324/// Matches `YYYY-MM-DD HH:MM:SS(.fff)? (Z|+HH:MM(:SS)?|...)` in both the
325/// standard RFC3339 shape and the space-separated `OffsetDateTime::to_string()`
326/// shape produced by the importer.
327fn parse_flexible(s: &str) -> Option<OffsetDateTime> {
328    static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
329    let re = RE.get_or_init(|| {
330        regex::Regex::new(
331            r"^(\d{4})-(\d{2})-(\d{2})[T ](\d{1,2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?(?:\s*(Z|[+-]\d{2}:\d{2}(?::\d{2})?))?$",
332        )
333        .expect("valid timestamp regex")
334    });
335
336    let caps = re.captures(s.trim())?;
337    let year: i32 = caps.get(1)?.as_str().parse().ok()?;
338    let month: u8 = caps.get(2)?.as_str().parse().ok()?;
339    let day: u8 = caps.get(3)?.as_str().parse().ok()?;
340    let hour: u8 = caps.get(4)?.as_str().parse().ok()?;
341    let minute: u8 = caps.get(5)?.as_str().parse().ok()?;
342    let second: u8 = caps
343        .get(6)
344        .map(|m| m.as_str().parse().ok())
345        .unwrap_or(Some(0))?;
346
347    let date =
348        time::Date::from_calendar_date(year, time::Month::try_from(month).ok()?, day).ok()?;
349    let time = time::Time::from_hms(hour, minute, second).ok()?;
350
351    match caps.get(7).map(|m| m.as_str()) {
352        None | Some("Z") => Some(date.with_time(time).assume_utc()),
353        Some(offset) => {
354            let sign: i8 = if offset.starts_with('-') { -1 } else { 1 };
355            let digits = offset.trim_start_matches(['+', '-']);
356            let mut parts = digits.split(':');
357            let oh: i8 = parts.next()?.parse().ok()?;
358            let om: i8 = parts.next()?.parse().ok()?;
359            let utc_offset = time::UtcOffset::from_hms(sign * oh, sign * om, 0).ok()?;
360            Some(date.with_time(time).assume_offset(utc_offset))
361        }
362    }
363}
364
365/// Parse RFC3339, the importer's space-separated timestamp shape, `YYYY-MM-DD
366/// HH:MM:SS UTC`, `YYYY-MM-DD`, or a unix epoch.
367pub fn parse_ts(value: &str) -> Option<OffsetDateTime> {
368    let trimmed = value.trim();
369    if let Ok(dt) = OffsetDateTime::parse(trimmed, &time::format_description::well_known::Rfc3339) {
370        return Some(dt);
371    }
372    let normalised = trimmed.replace(" UTC", "Z").replace(' ', "T");
373    if let Ok(dt) =
374        OffsetDateTime::parse(&normalised, &time::format_description::well_known::Rfc3339)
375    {
376        return Some(dt);
377    }
378    if let Some(dt) = parse_flexible(trimmed) {
379        return Some(dt);
380    }
381    if let Some(dt) = parse_date_only(trimmed) {
382        return Some(dt);
383    }
384    if let Ok(epoch) = trimmed.parse::<i64>() {
385        if let Ok(dt) = OffsetDateTime::from_unix_timestamp(epoch) {
386            return Some(dt);
387        }
388    }
389    None
390}
391
392/// Compact `YYYY-MM-DD HH:MM` (UTC) representation for tables.
393pub fn fmt_ts(value: &str) -> String {
394    if value.is_empty() {
395        return "-".to_string();
396    }
397    match parse_ts(value) {
398        Some(dt) => {
399            let utc = dt.to_offset(time::UtcOffset::UTC);
400            format!(
401                "{:04}-{:02}-{:02} {:02}:{:02}",
402                utc.year(),
403                u8::from(utc.month()),
404                utc.day(),
405                utc.hour(),
406                utc.minute()
407            )
408        }
409        None => truncate(value, 16).to_string(),
410    }
411}
412
413/// True if `ts` is at or after `since`.
414pub fn ts_since(ts: &str, since: OffsetDateTime) -> bool {
415    match parse_ts(ts) {
416        Some(dt) => dt >= since,
417        None => false,
418    }
419}
420
421// ---------------------------------------------------------------------------
422// Backend access
423// ---------------------------------------------------------------------------
424
425/// Open the configured backend, honouring `--backend` / `--pocketbase-url` /
426/// `--sqlite-path` overrides, and fail clearly when unconfigured/unreachable.
427pub fn open_backend(config_mgr: &ConfigManager, args: &Args) -> Result<Box<dyn Storage>> {
428    let db = config_mgr.get_database_config().clone();
429    if !db.configured {
430        return Err(AppError::Message(
431            "no database configured; run 'ploit-malper db_setup' first".to_string(),
432        ));
433    }
434    let backend = args.get("backend").unwrap_or(db.backend.as_str());
435    let mut storage = crate::db::open_storage_with(
436        &db,
437        backend,
438        args.get("pocketbase-url"),
439        args.get("sqlite-path"),
440    )?;
441    if !storage.ping()? {
442        return Err(AppError::Message(format!(
443            "{} backend is not reachable",
444            storage.kind()
445        )));
446    }
447    Ok(storage)
448}
449
450// ---------------------------------------------------------------------------
451// Id -> name maps
452// ---------------------------------------------------------------------------
453
454/// Lookup tables so foreign keys (asset_id, service_id) can be resolved to
455/// human-readable names across every frontend command.
456pub struct IdMaps {
457    pub assets: HashMap<String, Asset>,
458    pub services: HashMap<String, Service>,
459    pub findings: HashMap<String, Finding>,
460}
461
462impl IdMaps {
463    pub fn load(storage: &mut Box<dyn Storage>) -> Result<Self> {
464        let assets = storage
465            .list_assets()?
466            .into_iter()
467            .map(|a| (a.stable_id.clone(), a))
468            .collect();
469        let services = storage
470            .list_all_services()?
471            .into_iter()
472            .map(|s| (s.stable_id.clone(), s))
473            .collect();
474        let findings = storage
475            .list_all_findings()?
476            .into_iter()
477            .map(|f| (f.stable_id.clone(), f))
478            .collect();
479        Ok(Self {
480            assets,
481            services,
482            findings,
483        })
484    }
485
486    pub fn asset_name(&self, id: &str) -> String {
487        self.assets
488            .get(id)
489            .map(|a| a.name.clone())
490            .unwrap_or_else(|| id.to_string())
491    }
492
493    pub fn service_label(&self, id: &str) -> String {
494        match self.services.get(id) {
495            Some(s) => format!(
496                "{}/{}",
497                s.port,
498                if s.protocol.is_empty() {
499                    "tcp"
500                } else {
501                    &s.protocol
502                }
503            ),
504            None => id.to_string(),
505        }
506    }
507
508    pub fn service_summary(&self, asset_id: &str) -> String {
509        let mut services: Vec<&Service> = self
510            .services
511            .values()
512            .filter(|s| s.asset_id == asset_id && s.status != "removed")
513            .collect();
514        services.sort_by_key(|s| s.port);
515        if services.is_empty() {
516            return "-".to_string();
517        }
518        format!(
519            "{} open ({})",
520            services.len(),
521            services
522                .iter()
523                .map(|s| s.port.to_string())
524                .collect::<Vec<_>>()
525                .join(",")
526        )
527    }
528
529    pub fn finding_title(&self, id: &str) -> String {
530        self.findings
531            .get(id)
532            .map(|f| f.title.clone())
533            .unwrap_or_else(|| id.to_string())
534    }
535}
536
537// ---------------------------------------------------------------------------
538// Observation-derived change state
539// ---------------------------------------------------------------------------
540
541#[derive(Debug, Clone, Copy, PartialEq, Eq)]
542pub enum ChangeState {
543    New,
544    Changed,
545    Removed,
546    Unchanged,
547}
548
549impl ChangeState {
550    pub fn label(&self) -> &'static str {
551        match self {
552            ChangeState::New => "new",
553            ChangeState::Changed => "changed",
554            ChangeState::Removed => "removed",
555            ChangeState::Unchanged => "active",
556        }
557    }
558
559    pub fn paint(&self, text: &str) -> String {
560        match self {
561            ChangeState::New => green(text),
562            ChangeState::Changed => yellow(text),
563            ChangeState::Removed => red(text),
564            ChangeState::Unchanged => dim(text),
565        }
566    }
567}
568
569pub fn classify_kind(kind: &str) -> ChangeState {
570    if kind.ends_with("_discovered") {
571        ChangeState::New
572    } else if kind.ends_with("_removed") {
573        ChangeState::Removed
574    } else if kind.contains("changed") {
575        ChangeState::Changed
576    } else {
577        ChangeState::Unchanged
578    }
579}
580
581/// Index of observations by subject id for change-state lookups.
582pub struct ObsIndex {
583    by_subject: HashMap<String, Vec<Observation>>,
584    latest_run: String,
585}
586
587impl ObsIndex {
588    pub fn build(all: &[Observation]) -> Self {
589        let mut by_subject: HashMap<String, Vec<Observation>> = HashMap::new();
590        for obs in all {
591            by_subject
592                .entry(obs.subject_id.clone())
593                .or_default()
594                .push(obs.clone());
595        }
596        for values in by_subject.values_mut() {
597            values.sort_by(|a, b| a.observed_at.cmp(&b.observed_at));
598        }
599        let latest_run = all
600            .iter()
601            .max_by(|a, b| a.observed_at.cmp(&b.observed_at))
602            .map(|o| o.run_id.clone())
603            .unwrap_or_default();
604        Self {
605            by_subject,
606            latest_run,
607        }
608    }
609
610    pub fn observations_for(&self, id: &str) -> &[Observation] {
611        self.by_subject.get(id).map(|v| v.as_slice()).unwrap_or(&[])
612    }
613
614    /// The change state of a record as of the most recent run. `status` is the
615    /// record's stored lifecycle status (used to flag removals that predate
616    /// the latest run).
617    pub fn state(&self, id: &str, status: &str) -> ChangeState {
618        if status == crate::db::models::ASSET_REMOVED {
619            return ChangeState::Removed;
620        }
621        let last = self
622            .observations_for(id)
623            .iter()
624            .rev()
625            .find(|o| o.run_id == self.latest_run);
626        match last {
627            Some(obs) => classify_kind(&obs.kind),
628            None => ChangeState::Unchanged,
629        }
630    }
631
632    pub fn latest_run(&self) -> &str {
633        &self.latest_run
634    }
635}
636
637// ---------------------------------------------------------------------------
638// Subject resolution (history)
639// ---------------------------------------------------------------------------
640
641#[derive(Debug, Clone, Copy, PartialEq, Eq)]
642pub enum SubjectKind {
643    Asset,
644    Service,
645    Finding,
646}
647
648impl SubjectKind {
649    pub fn label(&self) -> &'static str {
650        match self {
651            SubjectKind::Asset => "asset",
652            SubjectKind::Service => "service",
653            SubjectKind::Finding => "finding",
654        }
655    }
656}
657
658pub struct ResolvedSubject {
659    pub kind: SubjectKind,
660    pub id: String,
661    pub label: String,
662}
663
664fn list_some(items: &[String], cap: usize) -> String {
665    items
666        .iter()
667        .take(cap)
668        .map(|s| format!("  - {}", s))
669        .collect::<Vec<_>>()
670        .join("\n")
671}
672
673/// Resolve a user-supplied id or name to a specific asset, service, or
674/// finding. Supports stable ids, exact names/titles, `asset:port` for
675/// services, and unambiguous substring matches.
676pub fn resolve_subject(maps: &IdMaps, term: &str) -> Result<ResolvedSubject> {
677    let lower = term.trim().to_lowercase();
678    if lower.is_empty() {
679        return Err(AppError::Message(
680            "history requires an asset, service, or finding id/name".to_string(),
681        ));
682    }
683
684    if let Some(asset) = maps.assets.get(&lower) {
685        return Ok(ResolvedSubject {
686            kind: SubjectKind::Asset,
687            id: asset.stable_id.clone(),
688            label: asset.name.clone(),
689        });
690    }
691    if let Some(service) = maps.services.get(&lower) {
692        return Ok(ResolvedSubject {
693            kind: SubjectKind::Service,
694            id: service.stable_id.clone(),
695            label: format!("{}:{}", maps.asset_name(&service.asset_id), service.port),
696        });
697    }
698    if let Some(finding) = maps.findings.get(&lower) {
699        return Ok(ResolvedSubject {
700            kind: SubjectKind::Finding,
701            id: finding.stable_id.clone(),
702            label: finding.title.clone(),
703        });
704    }
705
706    let asset_exact: Vec<&Asset> = maps
707        .assets
708        .values()
709        .filter(|a| {
710            a.name.eq_ignore_ascii_case(&lower)
711                || a.ip
712                    .as_deref()
713                    .is_some_and(|i| i.eq_ignore_ascii_case(&lower))
714                || a.fqdn
715                    .as_deref()
716                    .is_some_and(|f| f.eq_ignore_ascii_case(&lower))
717                || a.reverse_dns
718                    .as_deref()
719                    .is_some_and(|r| r.eq_ignore_ascii_case(&lower))
720        })
721        .collect();
722    if asset_exact.len() == 1 {
723        let a = asset_exact[0];
724        return Ok(ResolvedSubject {
725            kind: SubjectKind::Asset,
726            id: a.stable_id.clone(),
727            label: a.name.clone(),
728        });
729    }
730
731    let finding_exact: Vec<&Finding> = maps
732        .findings
733        .values()
734        .filter(|f| f.title.eq_ignore_ascii_case(&lower))
735        .collect();
736    if finding_exact.len() == 1 {
737        let f = finding_exact[0];
738        return Ok(ResolvedSubject {
739            kind: SubjectKind::Finding,
740            id: f.stable_id.clone(),
741            label: f.title.clone(),
742        });
743    }
744
745    // asset:port -> service
746    if let Some((name_part, port_part)) = lower.rsplit_once(':') {
747        if let Ok(port) = port_part.parse::<u16>() {
748            if let Some(asset) = maps
749                .assets
750                .values()
751                .find(|a| a.name.eq_ignore_ascii_case(name_part))
752            {
753                if let Some(service) = maps
754                    .services
755                    .values()
756                    .find(|s| s.asset_id == asset.stable_id && s.port == port)
757                {
758                    return Ok(ResolvedSubject {
759                        kind: SubjectKind::Service,
760                        id: service.stable_id.clone(),
761                        label: format!("{}:{}", asset.name, service.port),
762                    });
763                }
764            }
765        }
766    }
767
768    // Unambiguous substring matches.
769    let asset_contains: Vec<String> = maps
770        .assets
771        .values()
772        .filter(|a| {
773            a.name.to_lowercase().contains(&lower)
774                || a.ip
775                    .as_deref()
776                    .is_some_and(|i| i.to_lowercase().contains(&lower))
777                || a.fqdn
778                    .as_deref()
779                    .is_some_and(|f| f.to_lowercase().contains(&lower))
780        })
781        .map(|a| a.name.clone())
782        .collect();
783    if asset_contains.len() == 1 {
784        let name = &asset_contains[0];
785        let asset = maps.assets.values().find(|a| a.name == *name).unwrap();
786        return Ok(ResolvedSubject {
787            kind: SubjectKind::Asset,
788            id: asset.stable_id.clone(),
789            label: asset.name.clone(),
790        });
791    }
792
793    let finding_contains: Vec<String> = maps
794        .findings
795        .values()
796        .filter(|f| f.title.to_lowercase().contains(&lower))
797        .map(|f| f.title.clone())
798        .collect();
799    if finding_contains.len() == 1 {
800        let title = &finding_contains[0];
801        let finding = maps.findings.values().find(|f| f.title == *title).unwrap();
802        return Ok(ResolvedSubject {
803            kind: SubjectKind::Finding,
804            id: finding.stable_id.clone(),
805            label: finding.title.clone(),
806        });
807    }
808
809    if !asset_contains.is_empty() || !finding_contains.is_empty() {
810        let mut lines = Vec::new();
811        if !asset_contains.is_empty() {
812            lines.push(format!("Assets matching '{}':", term));
813            lines.push(list_some(&asset_contains, 5));
814        }
815        if !finding_contains.is_empty() {
816            lines.push(format!("Findings matching '{}':", term));
817            lines.push(list_some(&finding_contains, 5));
818        }
819        lines.push("Be more specific, or use a full id.".to_string());
820        return Err(AppError::Message(lines.join("\n")));
821    }
822
823    Err(AppError::Message(format!(
824        "no asset, service, or finding matches '{}'",
825        term
826    )))
827}
828
829// ---------------------------------------------------------------------------
830// JSON output
831// ---------------------------------------------------------------------------
832
833pub fn print_json<T: serde::Serialize>(value: &T) -> Result<()> {
834    println!("{}", serde_json::to_string_pretty(value)?);
835    Ok(())
836}
837
838/// Print a "nothing matched" message consistent across commands.
839pub fn empty_result(what: &str) {
840    println!("No {} found.", what);
841}
842
843/// Convert a `Value` to a compact single-line rendering for verbose output.
844pub fn value_to_short(value: &Value) -> String {
845    match value {
846        Value::Null => "-".to_string(),
847        Value::String(s) => truncate(s, 80).to_string(),
848        other => truncate(&other.to_string(), 120).to_string(),
849    }
850}