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