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