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
20const BOOLEAN_FLAGS: &[&str] = &[
25 "verbose",
26 "json",
27 "new",
28 "changed",
29 "fixed",
30 "help",
31 "dry-run",
32 "yes",
33 "tui",
34 "include-info",
35 "all",
36];
37const VALUE_FLAGS: &[&str] = &[
38 "target",
39 "since",
40 "port",
41 "product",
42 "asset",
43 "severity",
44 "cve",
45 "backend",
46 "pocketbase-url",
47 "sqlite-path",
48 "module",
49 "payload",
50 "workspace",
51 "job-timeout",
52 "status",
53 "lifecycle",
54];
55
56#[derive(Debug, Default, Clone)]
58pub struct Args {
59 flags: HashSet<String>,
60 values: HashMap<String, String>,
61 positionals: Vec<String>,
62}
63
64impl Args {
65 pub fn parse(args: &[String]) -> Result<Self> {
66 let mut flags = HashSet::new();
67 let mut values = HashMap::new();
68 let mut positionals = Vec::new();
69
70 let mut i = 0usize;
71 while i < args.len() {
72 let arg = &args[i];
73 if let Some(rest) = arg.strip_prefix("--") {
74 if let Some((key, value)) = rest.split_once('=') {
75 if VALUE_FLAGS.contains(&key) {
76 values.insert(key.to_string(), value.to_string());
77 i += 1;
78 continue;
79 }
80 return Err(AppError::Message(format!(
81 "unknown option: --{} (use --{} VALUE)",
82 key, key
83 )));
84 }
85 if VALUE_FLAGS.contains(&rest) {
86 let value = args.get(i + 1).ok_or_else(|| {
87 AppError::Message(format!("option --{} requires a value", rest))
88 })?;
89 values.insert(rest.to_string(), value.clone());
90 i += 2;
91 continue;
92 }
93 if BOOLEAN_FLAGS.contains(&rest) {
94 flags.insert(rest.to_string());
95 i += 1;
96 continue;
97 }
98 return Err(AppError::Message(format!("unknown option: --{}", rest)));
99 } else if arg.starts_with('-') && arg.len() > 1 {
100 let short = &arg[1..];
101 match short {
102 "v" => {
103 flags.insert("verbose".to_string());
104 }
105 "h" => {
106 flags.insert("help".to_string());
107 }
108 _ => {
109 return Err(AppError::Message(format!("unknown option: -{}", short)));
110 }
111 }
112 i += 1;
113 continue;
114 }
115 positionals.push(arg.clone());
116 i += 1;
117 }
118
119 Ok(Self {
120 flags,
121 values,
122 positionals,
123 })
124 }
125
126 pub fn has(&self, name: &str) -> bool {
127 self.flags.contains(name)
128 }
129
130 pub fn get(&self, name: &str) -> Option<&str> {
131 self.values.get(name).map(|s| s.as_str())
132 }
133
134 pub fn positionals(&self) -> &[String] {
135 &self.positionals
136 }
137}
138
139pub fn color_enabled() -> bool {
144 if std::env::var_os("NO_COLOR").is_some() {
145 return false;
146 }
147 io::stdout().is_terminal()
148}
149
150fn paint(text: &str, code: &str) -> String {
151 if color_enabled() {
152 format!("\x1b[{}m{}\x1b[0m", code, text)
153 } else {
154 text.to_string()
155 }
156}
157
158pub fn red(text: &str) -> String {
159 paint(text, "31")
160}
161
162pub fn green(text: &str) -> String {
163 paint(text, "32")
164}
165
166pub fn yellow(text: &str) -> String {
167 paint(text, "33")
168}
169
170pub fn cyan(text: &str) -> String {
171 paint(text, "36")
172}
173
174pub fn dim(text: &str) -> String {
175 paint(text, "2")
176}
177
178pub fn bold(text: &str) -> String {
179 paint(text, "1")
180}
181
182pub fn severity_style(severity: &str) -> &'static str {
183 match severity.to_lowercase().as_str() {
184 "critical" => "1;31",
185 "high" => "31",
186 "medium" => "33",
187 "low" => "34",
188 "info" | "none" => "2",
189 _ => "0",
190 }
191}
192
193pub fn severity_label(severity: &str) -> String {
194 severity.to_uppercase()
195}
196
197pub fn severity_rank(severity: &str) -> u8 {
199 match severity.to_lowercase().as_str() {
200 "critical" => 0,
201 "high" => 1,
202 "medium" => 2,
203 "low" => 3,
204 "info" => 4,
205 "none" => 5,
206 _ => 9,
207 }
208}
209
210pub struct Cell {
215 pub text: String,
216 pub style: Option<&'static str>,
217}
218
219impl Cell {
220 pub fn plain(text: String) -> Self {
221 Self { text, style: None }
222 }
223
224 pub fn styled(text: String, style: &'static str) -> Self {
225 Self {
226 text,
227 style: Some(style),
228 }
229 }
230}
231
232pub struct Table {
233 headers: Vec<String>,
234 rows: Vec<Vec<Cell>>,
235 max_width: usize,
236}
237
238impl Table {
239 pub fn new(headers: Vec<String>) -> Self {
240 Self {
241 headers,
242 rows: Vec::new(),
243 max_width: 60,
244 }
245 }
246
247 pub fn add_row(&mut self, cells: Vec<Cell>) {
248 self.rows.push(cells);
249 }
250
251 pub fn render(&self) -> String {
252 let mut widths: Vec<usize> = self.headers.iter().map(|h| h.chars().count()).collect();
253 for row in &self.rows {
254 for (i, cell) in row.iter().enumerate() {
255 if i < widths.len() {
256 widths[i] = widths[i].max(cell.text.chars().count());
257 }
258 }
259 }
260 for w in widths.iter_mut() {
261 *w = (*w).min(self.max_width);
262 }
263
264 let mut out = String::new();
265 let header_cells: Vec<String> = self
266 .headers
267 .iter()
268 .enumerate()
269 .map(|(i, h)| pad(&truncate(h, widths[i]), widths[i]))
270 .collect();
271 out.push_str(&header_cells.join(" "));
272 out.push('\n');
273
274 out.push_str(
275 &widths
276 .iter()
277 .map(|w| "-".repeat(*w))
278 .collect::<Vec<_>>()
279 .join(" "),
280 );
281 out.push('\n');
282
283 for row in &self.rows {
284 let mut line = String::new();
285 for (i, cell) in row.iter().enumerate() {
286 if i > 0 {
287 line.push_str(" ");
288 }
289 let width = widths.get(i).copied().unwrap_or(self.max_width);
290 let padded = pad(&truncate(&cell.text, width), width);
291 line.push_str(&match cell.style {
292 Some(code) => paint(&padded, code),
293 None => padded,
294 });
295 }
296 out.push_str(&line);
297 out.push('\n');
298 }
299 out
300 }
301}
302
303fn pad(text: &str, width: usize) -> String {
304 let len = text.chars().count();
305 if len >= width {
306 return text.to_string();
307 }
308 let mut out = text.to_string();
309 out.extend(std::iter::repeat_n(' ', width - len));
310 out
311}
312
313pub fn truncate(value: &str, width: usize) -> String {
314 let count = value.chars().count();
315 if count <= width {
316 return value.to_string();
317 }
318 if width <= 1 {
319 return "…".to_string();
320 }
321 let mut out: String = value.chars().take(width - 1).collect();
322 out.push('…');
323 out
324}
325
326fn parse_date_only(s: &str) -> Option<OffsetDateTime> {
331 let mut parts = s.split('-');
332 let year: i32 = parts.next()?.parse().ok()?;
333 let month: u8 = parts.next()?.parse().ok()?;
334 let day: u8 = parts.next()?.parse().ok()?;
335 if parts.next().is_some() {
336 return None;
337 }
338 let date =
339 time::Date::from_calendar_date(year, time::Month::try_from(month).ok()?, day).ok()?;
340 Some(date.midnight().assume_utc())
341}
342
343fn parse_flexible(s: &str) -> Option<OffsetDateTime> {
347 static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
348 let re = RE.get_or_init(|| {
349 regex::Regex::new(
350 r"^(\d{4})-(\d{2})-(\d{2})[T ](\d{1,2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?(?:\s*(Z|[+-]\d{2}:\d{2}(?::\d{2})?))?$",
351 )
352 .expect("valid timestamp regex")
353 });
354
355 let caps = re.captures(s.trim())?;
356 let year: i32 = caps.get(1)?.as_str().parse().ok()?;
357 let month: u8 = caps.get(2)?.as_str().parse().ok()?;
358 let day: u8 = caps.get(3)?.as_str().parse().ok()?;
359 let hour: u8 = caps.get(4)?.as_str().parse().ok()?;
360 let minute: u8 = caps.get(5)?.as_str().parse().ok()?;
361 let second: u8 = caps
362 .get(6)
363 .map(|m| m.as_str().parse().ok())
364 .unwrap_or(Some(0))?;
365
366 let date =
367 time::Date::from_calendar_date(year, time::Month::try_from(month).ok()?, day).ok()?;
368 let time = time::Time::from_hms(hour, minute, second).ok()?;
369
370 match caps.get(7).map(|m| m.as_str()) {
371 None | Some("Z") => Some(date.with_time(time).assume_utc()),
372 Some(offset) => {
373 let sign: i8 = if offset.starts_with('-') { -1 } else { 1 };
374 let digits = offset.trim_start_matches(['+', '-']);
375 let mut parts = digits.split(':');
376 let oh: i8 = parts.next()?.parse().ok()?;
377 let om: i8 = parts.next()?.parse().ok()?;
378 let utc_offset = time::UtcOffset::from_hms(sign * oh, sign * om, 0).ok()?;
379 Some(date.with_time(time).assume_offset(utc_offset))
380 }
381 }
382}
383
384pub fn parse_ts(value: &str) -> Option<OffsetDateTime> {
387 let trimmed = value.trim();
388 if let Ok(dt) = OffsetDateTime::parse(trimmed, &time::format_description::well_known::Rfc3339) {
389 return Some(dt);
390 }
391 let normalised = trimmed.replace(" UTC", "Z").replace(' ', "T");
392 if let Ok(dt) =
393 OffsetDateTime::parse(&normalised, &time::format_description::well_known::Rfc3339)
394 {
395 return Some(dt);
396 }
397 if let Some(dt) = parse_flexible(trimmed) {
398 return Some(dt);
399 }
400 if let Some(dt) = parse_date_only(trimmed) {
401 return Some(dt);
402 }
403 if let Ok(epoch) = trimmed.parse::<i64>() {
404 if let Ok(dt) = OffsetDateTime::from_unix_timestamp(epoch) {
405 return Some(dt);
406 }
407 }
408 None
409}
410
411pub fn fmt_ts(value: &str) -> String {
413 if value.is_empty() {
414 return "-".to_string();
415 }
416 match parse_ts(value) {
417 Some(dt) => {
418 let utc = dt.to_offset(time::UtcOffset::UTC);
419 format!(
420 "{:04}-{:02}-{:02} {:02}:{:02}",
421 utc.year(),
422 u8::from(utc.month()),
423 utc.day(),
424 utc.hour(),
425 utc.minute()
426 )
427 }
428 None => truncate(value, 16).to_string(),
429 }
430}
431
432pub fn ts_since(ts: &str, since: OffsetDateTime) -> bool {
434 match parse_ts(ts) {
435 Some(dt) => dt >= since,
436 None => false,
437 }
438}
439
440pub fn open_backend(config_mgr: &ConfigManager, args: &Args) -> Result<Box<dyn Storage>> {
447 crate::db::pocketbase::set_verbose(args.has("verbose"));
448 let db = config_mgr.get_database_config().clone();
449 if !db.configured {
450 return Err(AppError::Message(
451 "no database configured; run 'ploit-malper db_setup' first".to_string(),
452 ));
453 }
454 let backend = args.get("backend").unwrap_or(db.backend.as_str());
455 let mut storage = crate::db::open_storage_with(
456 &db,
457 backend,
458 args.get("pocketbase-url"),
459 args.get("sqlite-path"),
460 )?;
461 if !storage.ping()? {
462 return Err(AppError::Message(format!(
463 "{} backend is not reachable",
464 storage.kind()
465 )));
466 }
467 Ok(storage)
468}
469
470pub struct IdMaps {
477 pub assets: HashMap<String, Asset>,
478 pub services: HashMap<String, Service>,
479 pub findings: HashMap<String, Finding>,
480}
481
482impl IdMaps {
483 pub fn load(storage: &mut Box<dyn Storage>) -> Result<Self> {
484 let assets = storage
485 .list_assets()?
486 .into_iter()
487 .map(|a| (a.stable_id.clone(), a))
488 .collect();
489 let services = storage
490 .list_all_services()?
491 .into_iter()
492 .map(|s| (s.stable_id.clone(), s))
493 .collect();
494 let findings = storage
495 .list_all_findings()?
496 .into_iter()
497 .map(|f| (f.stable_id.clone(), f))
498 .collect();
499 Ok(Self {
500 assets,
501 services,
502 findings,
503 })
504 }
505
506 pub fn asset_name(&self, id: &str) -> String {
507 self.assets
508 .get(id)
509 .map(|a| a.name.clone())
510 .unwrap_or_else(|| id.to_string())
511 }
512
513 pub fn service_label(&self, id: &str) -> String {
514 match self.services.get(id) {
515 Some(s) => format!(
516 "{}/{}",
517 s.port,
518 if s.protocol.is_empty() {
519 "tcp"
520 } else {
521 &s.protocol
522 }
523 ),
524 None => id.to_string(),
525 }
526 }
527
528 pub fn service_summary(&self, asset_id: &str) -> String {
529 let mut services: Vec<&Service> = self
530 .services
531 .values()
532 .filter(|s| s.asset_id == asset_id && s.status != "removed")
533 .collect();
534 services.sort_by_key(|s| s.port);
535 if services.is_empty() {
536 return "-".to_string();
537 }
538 format!(
539 "{} open ({})",
540 services.len(),
541 services
542 .iter()
543 .map(|s| s.port.to_string())
544 .collect::<Vec<_>>()
545 .join(",")
546 )
547 }
548
549 pub fn finding_title(&self, id: &str) -> String {
550 self.findings
551 .get(id)
552 .map(|f| f.title.clone())
553 .unwrap_or_else(|| id.to_string())
554 }
555}
556
557#[derive(Debug, Clone, Copy, PartialEq, Eq)]
566pub enum Lifecycle {
567 New,
568 Active,
569 Changed,
570 Removed,
571}
572
573#[derive(Debug, Clone, Copy, PartialEq, Eq)]
577pub enum ChangeState {
578 New,
579 Changed,
580 Removed,
581 Unchanged,
582}
583
584impl ChangeState {
585 pub fn label(&self) -> &'static str {
586 match self {
587 ChangeState::New => "new",
588 ChangeState::Changed => "changed",
589 ChangeState::Removed => "removed",
590 ChangeState::Unchanged => "active",
591 }
592 }
593
594 pub fn paint(&self, text: &str) -> String {
595 match self {
596 ChangeState::New => green(text),
597 ChangeState::Changed => yellow(text),
598 ChangeState::Removed => red(text),
599 ChangeState::Unchanged => dim(text),
600 }
601 }
602}
603
604pub const LIFECYCLE_NAMES: &[&str] = &["new", "active", "unchanged", "changed", "removed", "fixed"];
606
607impl Lifecycle {
608 pub fn label(&self) -> &'static str {
609 match self {
610 Lifecycle::New => "new",
611 Lifecycle::Active => "active",
612 Lifecycle::Changed => "changed",
613 Lifecycle::Removed => "removed",
614 }
615 }
616
617 pub fn resolve(status: &str, run_state: Self) -> Self {
619 if status == crate::db::models::ASSET_REMOVED {
620 return Lifecycle::Removed;
621 }
622 run_state
623 }
624
625 pub fn matches(&self, name: &str) -> bool {
628 match name {
629 "new" => matches!(self, Lifecycle::New),
630 "active" | "unchanged" => matches!(self, Lifecycle::Active),
631 "changed" => matches!(self, Lifecycle::Changed),
632 "removed" | "fixed" => matches!(self, Lifecycle::Removed),
633 _ => false,
634 }
635 }
636
637 pub fn paint(&self, text: &str) -> String {
638 match self {
639 Lifecycle::New => green(text),
640 Lifecycle::Changed => yellow(text),
641 Lifecycle::Removed => red(text),
642 Lifecycle::Active => dim(text),
643 }
644 }
645}
646
647impl From<ChangeState> for Lifecycle {
648 fn from(state: ChangeState) -> Self {
649 match state {
650 ChangeState::New => Lifecycle::New,
651 ChangeState::Changed => Lifecycle::Changed,
652 ChangeState::Removed => Lifecycle::Removed,
653 ChangeState::Unchanged => Lifecycle::Active,
654 }
655 }
656}
657
658impl From<LifecycleFilter> for Lifecycle {
659 fn from(filter: LifecycleFilter) -> Self {
660 match filter {
661 LifecycleFilter::New => Lifecycle::New,
662 LifecycleFilter::Active => Lifecycle::Active,
663 LifecycleFilter::Changed => Lifecycle::Changed,
664 LifecycleFilter::Removed => Lifecycle::Removed,
665 }
666 }
667}
668
669pub fn classify_kind(kind: &str) -> ChangeState {
670 if kind.ends_with("_discovered") {
671 ChangeState::New
672 } else if kind.ends_with("_removed") {
673 ChangeState::Removed
674 } else if kind.contains("changed") {
675 ChangeState::Changed
676 } else {
677 ChangeState::Unchanged
678 }
679}
680
681pub struct ObsIndex {
683 by_subject: HashMap<String, Vec<Observation>>,
684 latest_run: String,
685}
686
687impl ObsIndex {
688 pub fn build(all: &[Observation]) -> Self {
689 let mut by_subject: HashMap<String, Vec<Observation>> = HashMap::new();
690 for obs in all {
691 by_subject
692 .entry(obs.subject_id.clone())
693 .or_default()
694 .push(obs.clone());
695 }
696 for values in by_subject.values_mut() {
697 values.sort_by(|a, b| a.observed_at.cmp(&b.observed_at));
698 }
699 let latest_run = all
700 .iter()
701 .max_by(|a, b| a.observed_at.cmp(&b.observed_at))
702 .map(|o| o.run_id.clone())
703 .unwrap_or_default();
704 Self {
705 by_subject,
706 latest_run,
707 }
708 }
709
710 pub fn observations_for(&self, id: &str) -> &[Observation] {
711 self.by_subject.get(id).map(|v| v.as_slice()).unwrap_or(&[])
712 }
713
714 pub fn state(&self, id: &str, status: &str) -> ChangeState {
718 if status == crate::db::models::ASSET_REMOVED {
719 return ChangeState::Removed;
720 }
721 let last = self
722 .observations_for(id)
723 .iter()
724 .rev()
725 .find(|o| o.run_id == self.latest_run);
726 match last {
727 Some(obs) => classify_kind(&obs.kind),
728 None => ChangeState::Unchanged,
729 }
730 }
731
732 pub fn lifecycle(&self, id: &str, status: &str) -> Lifecycle {
735 Lifecycle::resolve(status, self.state(id, status).into())
736 }
737
738 pub fn latest_run(&self) -> &str {
739 &self.latest_run
740 }
741}
742
743#[derive(Debug, Clone, Copy, PartialEq, Eq)]
748pub enum SubjectKind {
749 Asset,
750 Service,
751 Finding,
752}
753
754impl SubjectKind {
755 pub fn label(&self) -> &'static str {
756 match self {
757 SubjectKind::Asset => "asset",
758 SubjectKind::Service => "service",
759 SubjectKind::Finding => "finding",
760 }
761 }
762}
763
764pub struct ResolvedSubject {
765 pub kind: SubjectKind,
766 pub id: String,
767 pub label: String,
768}
769
770fn list_some(items: &[String], cap: usize) -> String {
771 items
772 .iter()
773 .take(cap)
774 .map(|s| format!(" - {}", s))
775 .collect::<Vec<_>>()
776 .join("\n")
777}
778
779pub fn resolve_subject(maps: &IdMaps, term: &str) -> Result<ResolvedSubject> {
783 let lower = term.trim().to_lowercase();
784 if lower.is_empty() {
785 return Err(AppError::Message(
786 "history requires an asset, service, or finding id/name".to_string(),
787 ));
788 }
789
790 if let Some(asset) = maps.assets.get(&lower) {
791 return Ok(ResolvedSubject {
792 kind: SubjectKind::Asset,
793 id: asset.stable_id.clone(),
794 label: asset.name.clone(),
795 });
796 }
797 if let Some(service) = maps.services.get(&lower) {
798 return Ok(ResolvedSubject {
799 kind: SubjectKind::Service,
800 id: service.stable_id.clone(),
801 label: format!("{}:{}", maps.asset_name(&service.asset_id), service.port),
802 });
803 }
804 if let Some(finding) = maps.findings.get(&lower) {
805 return Ok(ResolvedSubject {
806 kind: SubjectKind::Finding,
807 id: finding.stable_id.clone(),
808 label: finding.title.clone(),
809 });
810 }
811
812 let asset_exact: Vec<&Asset> = maps
813 .assets
814 .values()
815 .filter(|a| {
816 a.name.eq_ignore_ascii_case(&lower)
817 || a.ip
818 .as_deref()
819 .is_some_and(|i| i.eq_ignore_ascii_case(&lower))
820 || a.fqdn
821 .as_deref()
822 .is_some_and(|f| f.eq_ignore_ascii_case(&lower))
823 || a.reverse_dns
824 .as_deref()
825 .is_some_and(|r| r.eq_ignore_ascii_case(&lower))
826 })
827 .collect();
828 if asset_exact.len() == 1 {
829 let a = asset_exact[0];
830 return Ok(ResolvedSubject {
831 kind: SubjectKind::Asset,
832 id: a.stable_id.clone(),
833 label: a.name.clone(),
834 });
835 }
836
837 let finding_exact: Vec<&Finding> = maps
838 .findings
839 .values()
840 .filter(|f| f.title.eq_ignore_ascii_case(&lower))
841 .collect();
842 if finding_exact.len() == 1 {
843 let f = finding_exact[0];
844 return Ok(ResolvedSubject {
845 kind: SubjectKind::Finding,
846 id: f.stable_id.clone(),
847 label: f.title.clone(),
848 });
849 }
850
851 if let Some((name_part, port_part)) = lower.rsplit_once(':') {
853 if let Ok(port) = port_part.parse::<u16>() {
854 if let Some(asset) = maps
855 .assets
856 .values()
857 .find(|a| a.name.eq_ignore_ascii_case(name_part))
858 {
859 if let Some(service) = maps
860 .services
861 .values()
862 .find(|s| s.asset_id == asset.stable_id && s.port == port)
863 {
864 return Ok(ResolvedSubject {
865 kind: SubjectKind::Service,
866 id: service.stable_id.clone(),
867 label: format!("{}:{}", asset.name, service.port),
868 });
869 }
870 }
871 }
872 }
873
874 let asset_contains: Vec<String> = maps
876 .assets
877 .values()
878 .filter(|a| {
879 a.name.to_lowercase().contains(&lower)
880 || a.ip
881 .as_deref()
882 .is_some_and(|i| i.to_lowercase().contains(&lower))
883 || a.fqdn
884 .as_deref()
885 .is_some_and(|f| f.to_lowercase().contains(&lower))
886 })
887 .map(|a| a.name.clone())
888 .collect();
889 if asset_contains.len() == 1 {
890 let name = &asset_contains[0];
891 let asset = maps.assets.values().find(|a| a.name == *name).unwrap();
892 return Ok(ResolvedSubject {
893 kind: SubjectKind::Asset,
894 id: asset.stable_id.clone(),
895 label: asset.name.clone(),
896 });
897 }
898
899 let finding_contains: Vec<String> = maps
900 .findings
901 .values()
902 .filter(|f| f.title.to_lowercase().contains(&lower))
903 .map(|f| f.title.clone())
904 .collect();
905 if finding_contains.len() == 1 {
906 let title = &finding_contains[0];
907 let finding = maps.findings.values().find(|f| f.title == *title).unwrap();
908 return Ok(ResolvedSubject {
909 kind: SubjectKind::Finding,
910 id: finding.stable_id.clone(),
911 label: finding.title.clone(),
912 });
913 }
914
915 if !asset_contains.is_empty() || !finding_contains.is_empty() {
916 let mut lines = Vec::new();
917 if !asset_contains.is_empty() {
918 lines.push(format!("Assets matching '{}':", term));
919 lines.push(list_some(&asset_contains, 5));
920 }
921 if !finding_contains.is_empty() {
922 lines.push(format!("Findings matching '{}':", term));
923 lines.push(list_some(&finding_contains, 5));
924 }
925 lines.push("Be more specific, or use a full id.".to_string());
926 return Err(AppError::Message(lines.join("\n")));
927 }
928
929 Err(AppError::Message(format!(
930 "no asset, service, or finding matches '{}'",
931 term
932 )))
933}
934
935pub fn print_json<T: serde::Serialize>(value: &T) -> Result<()> {
940 println!("{}", serde_json::to_string_pretty(value)?);
941 Ok(())
942}
943
944pub fn empty_result(what: &str) {
946 println!("No {} found.", what);
947}
948
949pub fn value_to_short(value: &Value) -> String {
951 match value {
952 Value::Null => "-".to_string(),
953 Value::String(s) => truncate(s, 80).to_string(),
954 other => truncate(&other.to_string(), 120).to_string(),
955 }
956}
957
958#[derive(Debug, Clone, Copy, PartialEq, Eq)]
960pub enum LifecycleFilter {
961 New,
962 Active,
963 Changed,
964 Removed,
965}
966
967impl LifecycleFilter {
968 pub fn parse_term(raw: &str) -> Result<Self> {
972 match raw.to_lowercase().as_str() {
973 "new" => Ok(LifecycleFilter::New),
974 "active" | "unchanged" => Ok(LifecycleFilter::Active),
975 "changed" => Ok(LifecycleFilter::Changed),
976 "removed" | "fixed" => Ok(LifecycleFilter::Removed),
977 other => Err(AppError::Message(format!(
978 "unknown lifecycle '{}' (use one of: {})",
979 other,
980 LIFECYCLE_NAMES.join(", ")
981 ))),
982 }
983 }
984
985 pub fn matches(&self, lifecycle: Lifecycle) -> bool {
986 match self {
987 LifecycleFilter::New => matches!(lifecycle, Lifecycle::New),
988 LifecycleFilter::Active => matches!(lifecycle, Lifecycle::Active),
989 LifecycleFilter::Changed => matches!(lifecycle, Lifecycle::Changed),
990 LifecycleFilter::Removed => matches!(lifecycle, Lifecycle::Removed),
991 }
992 }
993}
994
995#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1001pub enum LifecycleQuery {
1002 All,
1004 Filtered(LifecycleFilter),
1006 Default,
1008}
1009
1010impl LifecycleQuery {
1011 pub fn parse(args: &Args, override_new: bool, override_fixed: bool) -> Result<Self> {
1016 if let Some(raw) = args.get("lifecycle").or_else(|| args.get("status")) {
1017 return Ok(LifecycleQuery::Filtered(LifecycleFilter::parse_term(raw)?));
1018 }
1019 if args.has("all") {
1020 return Ok(LifecycleQuery::All);
1021 }
1022 if override_new && args.has("new") {
1023 return Ok(LifecycleQuery::Filtered(LifecycleFilter::New));
1024 }
1025 if override_fixed && args.has("fixed") {
1026 return Ok(LifecycleQuery::Filtered(LifecycleFilter::Removed));
1027 }
1028 if args.has("changed") {
1029 return Ok(LifecycleQuery::Filtered(LifecycleFilter::Changed));
1030 }
1031 Ok(LifecycleQuery::Default)
1032 }
1033
1034 pub fn matches(&self, lifecycle: Lifecycle) -> bool {
1035 match self {
1036 LifecycleQuery::All => true,
1037 LifecycleQuery::Filtered(filter) => filter.matches(lifecycle),
1038 LifecycleQuery::Default => lifecycle != Lifecycle::Removed,
1039 }
1040 }
1041
1042 pub fn label(&self) -> Option<&'static str> {
1043 match self {
1044 LifecycleQuery::All => Some("all"),
1045 LifecycleQuery::Filtered(filter) => match filter {
1046 LifecycleFilter::New => Some("new"),
1047 LifecycleFilter::Active => Some("active"),
1048 LifecycleFilter::Changed => Some("changed"),
1049 LifecycleFilter::Removed => Some("removed"),
1050 },
1051 LifecycleQuery::Default => None,
1052 }
1053 }
1054}
1055
1056#[cfg(test)]
1057mod lifecycle_tests {
1058 use super::*;
1059
1060 #[test]
1061 fn lifecycle_terms_alias() {
1062 assert_eq!(
1063 LifecycleFilter::parse_term("active").unwrap(),
1064 LifecycleFilter::Active
1065 );
1066 assert_eq!(
1067 LifecycleFilter::parse_term("unchanged").unwrap(),
1068 LifecycleFilter::Active
1069 );
1070 assert_eq!(
1071 LifecycleFilter::parse_term("fixed").unwrap(),
1072 LifecycleFilter::Removed
1073 );
1074 assert!(LifecycleFilter::parse_term("bogus").is_err());
1075 }
1076
1077 #[test]
1078 fn lifecycle_matches() {
1079 assert!(LifecycleFilter::New.matches(Lifecycle::New));
1080 assert!(!LifecycleFilter::New.matches(Lifecycle::Active));
1081 assert!(LifecycleFilter::Active.matches(Lifecycle::Active));
1082 assert!(LifecycleFilter::Changed.matches(Lifecycle::Changed));
1083 assert!(LifecycleFilter::Removed.matches(Lifecycle::Removed));
1084 }
1085
1086 #[test]
1087 fn query_default_hides_removed() {
1088 let none = Args::parse(&[]).unwrap();
1089 let query = LifecycleQuery::parse(&none, true, false).unwrap();
1090 assert_eq!(query, LifecycleQuery::Default);
1091 assert!(query.matches(Lifecycle::New));
1092 assert!(query.matches(Lifecycle::Active));
1093 assert!(query.matches(Lifecycle::Changed));
1094 assert!(!query.matches(Lifecycle::Removed));
1095 }
1096
1097 #[test]
1098 fn all_flag_includes_removed() {
1099 let all = Args::parse(&["--all".to_string()]).unwrap();
1100 let query = LifecycleQuery::parse(&all, true, false).unwrap();
1101 assert_eq!(query, LifecycleQuery::All);
1102 assert!(query.matches(Lifecycle::Removed));
1103 }
1104
1105 #[test]
1106 fn lifecycle_and_status_are_interchangeable() {
1107 let by_lifecycle = Args::parse(&["--lifecycle".to_string(), "new".to_string()]).unwrap();
1108 let by_status = Args::parse(&["--status".to_string(), "new".to_string()]).unwrap();
1109 assert_eq!(
1110 LifecycleQuery::parse(&by_lifecycle, true, false).unwrap(),
1111 LifecycleQuery::Filtered(LifecycleFilter::New)
1112 );
1113 assert_eq!(
1114 LifecycleQuery::parse(&by_status, true, false).unwrap(),
1115 LifecycleQuery::Filtered(LifecycleFilter::New)
1116 );
1117 assert!(LifecycleQuery::parse(&by_lifecycle, true, false)
1118 .unwrap()
1119 .matches(Lifecycle::New));
1120 }
1121
1122 #[test]
1123 fn singleton_flags_map() {
1124 let new = Args::parse(&["--new".to_string()]).unwrap();
1125 assert_eq!(
1126 LifecycleQuery::parse(&new, true, false).unwrap(),
1127 LifecycleQuery::Filtered(LifecycleFilter::New)
1128 );
1129 let changed = Args::parse(&["--changed".to_string()]).unwrap();
1130 assert_eq!(
1131 LifecycleQuery::parse(&changed, true, false).unwrap(),
1132 LifecycleQuery::Filtered(LifecycleFilter::Changed)
1133 );
1134 let fixed = Args::parse(&["--fixed".to_string()]).unwrap();
1135 assert_eq!(
1136 LifecycleQuery::parse(&fixed, false, true).unwrap(),
1137 LifecycleQuery::Filtered(LifecycleFilter::Removed)
1138 );
1139 }
1140
1141 #[test]
1142 fn legacy_new_flag_is_ignored_when_unscoped() {
1143 let new = Args::parse(&["--new".to_string()]).unwrap();
1144 assert_eq!(
1145 LifecycleQuery::parse(&new, false, false).unwrap(),
1146 LifecycleQuery::Default
1147 );
1148 }
1149}