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    crate::db::pocketbase::set_verbose(args.has("verbose"));
435    let db = config_mgr.get_database_config().clone();
436    if !db.configured {
437        return Err(AppError::Message(
438            "no database configured; run 'ploit-malper db_setup' first".to_string(),
439        ));
440    }
441    let backend = args.get("backend").unwrap_or(db.backend.as_str());
442    let mut storage = crate::db::open_storage_with(
443        &db,
444        backend,
445        args.get("pocketbase-url"),
446        args.get("sqlite-path"),
447    )?;
448    if !storage.ping()? {
449        return Err(AppError::Message(format!(
450            "{} backend is not reachable",
451            storage.kind()
452        )));
453    }
454    Ok(storage)
455}
456
457// ---------------------------------------------------------------------------
458// Id -> name maps
459// ---------------------------------------------------------------------------
460
461/// Lookup tables so foreign keys (asset_id, service_id) can be resolved to
462/// human-readable names across every frontend command.
463pub struct IdMaps {
464    pub assets: HashMap<String, Asset>,
465    pub services: HashMap<String, Service>,
466    pub findings: HashMap<String, Finding>,
467}
468
469impl IdMaps {
470    pub fn load(storage: &mut Box<dyn Storage>) -> Result<Self> {
471        let assets = storage
472            .list_assets()?
473            .into_iter()
474            .map(|a| (a.stable_id.clone(), a))
475            .collect();
476        let services = storage
477            .list_all_services()?
478            .into_iter()
479            .map(|s| (s.stable_id.clone(), s))
480            .collect();
481        let findings = storage
482            .list_all_findings()?
483            .into_iter()
484            .map(|f| (f.stable_id.clone(), f))
485            .collect();
486        Ok(Self {
487            assets,
488            services,
489            findings,
490        })
491    }
492
493    pub fn asset_name(&self, id: &str) -> String {
494        self.assets
495            .get(id)
496            .map(|a| a.name.clone())
497            .unwrap_or_else(|| id.to_string())
498    }
499
500    pub fn service_label(&self, id: &str) -> String {
501        match self.services.get(id) {
502            Some(s) => format!(
503                "{}/{}",
504                s.port,
505                if s.protocol.is_empty() {
506                    "tcp"
507                } else {
508                    &s.protocol
509                }
510            ),
511            None => id.to_string(),
512        }
513    }
514
515    pub fn service_summary(&self, asset_id: &str) -> String {
516        let mut services: Vec<&Service> = self
517            .services
518            .values()
519            .filter(|s| s.asset_id == asset_id && s.status != "removed")
520            .collect();
521        services.sort_by_key(|s| s.port);
522        if services.is_empty() {
523            return "-".to_string();
524        }
525        format!(
526            "{} open ({})",
527            services.len(),
528            services
529                .iter()
530                .map(|s| s.port.to_string())
531                .collect::<Vec<_>>()
532                .join(",")
533        )
534    }
535
536    pub fn finding_title(&self, id: &str) -> String {
537        self.findings
538            .get(id)
539            .map(|f| f.title.clone())
540            .unwrap_or_else(|| id.to_string())
541    }
542}
543
544// ---------------------------------------------------------------------------
545// Observation-derived change state
546// ---------------------------------------------------------------------------
547
548#[derive(Debug, Clone, Copy, PartialEq, Eq)]
549pub enum ChangeState {
550    New,
551    Changed,
552    Removed,
553    Unchanged,
554}
555
556impl ChangeState {
557    pub fn label(&self) -> &'static str {
558        match self {
559            ChangeState::New => "new",
560            ChangeState::Changed => "changed",
561            ChangeState::Removed => "removed",
562            ChangeState::Unchanged => "active",
563        }
564    }
565
566    pub fn paint(&self, text: &str) -> String {
567        match self {
568            ChangeState::New => green(text),
569            ChangeState::Changed => yellow(text),
570            ChangeState::Removed => red(text),
571            ChangeState::Unchanged => dim(text),
572        }
573    }
574}
575
576pub fn classify_kind(kind: &str) -> ChangeState {
577    if kind.ends_with("_discovered") {
578        ChangeState::New
579    } else if kind.ends_with("_removed") {
580        ChangeState::Removed
581    } else if kind.contains("changed") {
582        ChangeState::Changed
583    } else {
584        ChangeState::Unchanged
585    }
586}
587
588/// Index of observations by subject id for change-state lookups.
589pub struct ObsIndex {
590    by_subject: HashMap<String, Vec<Observation>>,
591    latest_run: String,
592}
593
594impl ObsIndex {
595    pub fn build(all: &[Observation]) -> Self {
596        let mut by_subject: HashMap<String, Vec<Observation>> = HashMap::new();
597        for obs in all {
598            by_subject
599                .entry(obs.subject_id.clone())
600                .or_default()
601                .push(obs.clone());
602        }
603        for values in by_subject.values_mut() {
604            values.sort_by(|a, b| a.observed_at.cmp(&b.observed_at));
605        }
606        let latest_run = all
607            .iter()
608            .max_by(|a, b| a.observed_at.cmp(&b.observed_at))
609            .map(|o| o.run_id.clone())
610            .unwrap_or_default();
611        Self {
612            by_subject,
613            latest_run,
614        }
615    }
616
617    pub fn observations_for(&self, id: &str) -> &[Observation] {
618        self.by_subject.get(id).map(|v| v.as_slice()).unwrap_or(&[])
619    }
620
621    /// The change state of a record as of the most recent run. `status` is the
622    /// record's stored lifecycle status (used to flag removals that predate
623    /// the latest run).
624    pub fn state(&self, id: &str, status: &str) -> ChangeState {
625        if status == crate::db::models::ASSET_REMOVED {
626            return ChangeState::Removed;
627        }
628        let last = self
629            .observations_for(id)
630            .iter()
631            .rev()
632            .find(|o| o.run_id == self.latest_run);
633        match last {
634            Some(obs) => classify_kind(&obs.kind),
635            None => ChangeState::Unchanged,
636        }
637    }
638
639    pub fn latest_run(&self) -> &str {
640        &self.latest_run
641    }
642}
643
644// ---------------------------------------------------------------------------
645// Subject resolution (history)
646// ---------------------------------------------------------------------------
647
648#[derive(Debug, Clone, Copy, PartialEq, Eq)]
649pub enum SubjectKind {
650    Asset,
651    Service,
652    Finding,
653}
654
655impl SubjectKind {
656    pub fn label(&self) -> &'static str {
657        match self {
658            SubjectKind::Asset => "asset",
659            SubjectKind::Service => "service",
660            SubjectKind::Finding => "finding",
661        }
662    }
663}
664
665pub struct ResolvedSubject {
666    pub kind: SubjectKind,
667    pub id: String,
668    pub label: String,
669}
670
671fn list_some(items: &[String], cap: usize) -> String {
672    items
673        .iter()
674        .take(cap)
675        .map(|s| format!("  - {}", s))
676        .collect::<Vec<_>>()
677        .join("\n")
678}
679
680/// Resolve a user-supplied id or name to a specific asset, service, or
681/// finding. Supports stable ids, exact names/titles, `asset:port` for
682/// services, and unambiguous substring matches.
683pub fn resolve_subject(maps: &IdMaps, term: &str) -> Result<ResolvedSubject> {
684    let lower = term.trim().to_lowercase();
685    if lower.is_empty() {
686        return Err(AppError::Message(
687            "history requires an asset, service, or finding id/name".to_string(),
688        ));
689    }
690
691    if let Some(asset) = maps.assets.get(&lower) {
692        return Ok(ResolvedSubject {
693            kind: SubjectKind::Asset,
694            id: asset.stable_id.clone(),
695            label: asset.name.clone(),
696        });
697    }
698    if let Some(service) = maps.services.get(&lower) {
699        return Ok(ResolvedSubject {
700            kind: SubjectKind::Service,
701            id: service.stable_id.clone(),
702            label: format!("{}:{}", maps.asset_name(&service.asset_id), service.port),
703        });
704    }
705    if let Some(finding) = maps.findings.get(&lower) {
706        return Ok(ResolvedSubject {
707            kind: SubjectKind::Finding,
708            id: finding.stable_id.clone(),
709            label: finding.title.clone(),
710        });
711    }
712
713    let asset_exact: Vec<&Asset> = maps
714        .assets
715        .values()
716        .filter(|a| {
717            a.name.eq_ignore_ascii_case(&lower)
718                || a.ip
719                    .as_deref()
720                    .is_some_and(|i| i.eq_ignore_ascii_case(&lower))
721                || a.fqdn
722                    .as_deref()
723                    .is_some_and(|f| f.eq_ignore_ascii_case(&lower))
724                || a.reverse_dns
725                    .as_deref()
726                    .is_some_and(|r| r.eq_ignore_ascii_case(&lower))
727        })
728        .collect();
729    if asset_exact.len() == 1 {
730        let a = asset_exact[0];
731        return Ok(ResolvedSubject {
732            kind: SubjectKind::Asset,
733            id: a.stable_id.clone(),
734            label: a.name.clone(),
735        });
736    }
737
738    let finding_exact: Vec<&Finding> = maps
739        .findings
740        .values()
741        .filter(|f| f.title.eq_ignore_ascii_case(&lower))
742        .collect();
743    if finding_exact.len() == 1 {
744        let f = finding_exact[0];
745        return Ok(ResolvedSubject {
746            kind: SubjectKind::Finding,
747            id: f.stable_id.clone(),
748            label: f.title.clone(),
749        });
750    }
751
752    // asset:port -> service
753    if let Some((name_part, port_part)) = lower.rsplit_once(':') {
754        if let Ok(port) = port_part.parse::<u16>() {
755            if let Some(asset) = maps
756                .assets
757                .values()
758                .find(|a| a.name.eq_ignore_ascii_case(name_part))
759            {
760                if let Some(service) = maps
761                    .services
762                    .values()
763                    .find(|s| s.asset_id == asset.stable_id && s.port == port)
764                {
765                    return Ok(ResolvedSubject {
766                        kind: SubjectKind::Service,
767                        id: service.stable_id.clone(),
768                        label: format!("{}:{}", asset.name, service.port),
769                    });
770                }
771            }
772        }
773    }
774
775    // Unambiguous substring matches.
776    let asset_contains: Vec<String> = maps
777        .assets
778        .values()
779        .filter(|a| {
780            a.name.to_lowercase().contains(&lower)
781                || a.ip
782                    .as_deref()
783                    .is_some_and(|i| i.to_lowercase().contains(&lower))
784                || a.fqdn
785                    .as_deref()
786                    .is_some_and(|f| f.to_lowercase().contains(&lower))
787        })
788        .map(|a| a.name.clone())
789        .collect();
790    if asset_contains.len() == 1 {
791        let name = &asset_contains[0];
792        let asset = maps.assets.values().find(|a| a.name == *name).unwrap();
793        return Ok(ResolvedSubject {
794            kind: SubjectKind::Asset,
795            id: asset.stable_id.clone(),
796            label: asset.name.clone(),
797        });
798    }
799
800    let finding_contains: Vec<String> = maps
801        .findings
802        .values()
803        .filter(|f| f.title.to_lowercase().contains(&lower))
804        .map(|f| f.title.clone())
805        .collect();
806    if finding_contains.len() == 1 {
807        let title = &finding_contains[0];
808        let finding = maps.findings.values().find(|f| f.title == *title).unwrap();
809        return Ok(ResolvedSubject {
810            kind: SubjectKind::Finding,
811            id: finding.stable_id.clone(),
812            label: finding.title.clone(),
813        });
814    }
815
816    if !asset_contains.is_empty() || !finding_contains.is_empty() {
817        let mut lines = Vec::new();
818        if !asset_contains.is_empty() {
819            lines.push(format!("Assets matching '{}':", term));
820            lines.push(list_some(&asset_contains, 5));
821        }
822        if !finding_contains.is_empty() {
823            lines.push(format!("Findings matching '{}':", term));
824            lines.push(list_some(&finding_contains, 5));
825        }
826        lines.push("Be more specific, or use a full id.".to_string());
827        return Err(AppError::Message(lines.join("\n")));
828    }
829
830    Err(AppError::Message(format!(
831        "no asset, service, or finding matches '{}'",
832        term
833    )))
834}
835
836// ---------------------------------------------------------------------------
837// JSON output
838// ---------------------------------------------------------------------------
839
840pub fn print_json<T: serde::Serialize>(value: &T) -> Result<()> {
841    println!("{}", serde_json::to_string_pretty(value)?);
842    Ok(())
843}
844
845/// Print a "nothing matched" message consistent across commands.
846pub fn empty_result(what: &str) {
847    println!("No {} found.", what);
848}
849
850/// Convert a `Value` to a compact single-line rendering for verbose output.
851pub fn value_to_short(value: &Value) -> String {
852    match value {
853        Value::Null => "-".to_string(),
854        Value::String(s) => truncate(s, 80).to_string(),
855        other => truncate(&other.to_string(), 120).to_string(),
856    }
857}