Skip to main content

bestool_kopia/
lib.rs

1//! Shared helpers for interacting with the kopia CLI.
2//!
3//! Used by `bestool-tamanu` (for the `kopia_backup` doctor check) and by
4//! `bestool` (for the `bestool kopia` subcommand suite). Has nothing
5//! tamanu-specific in it.
6//!
7//! Highlights:
8//! - [`find_kopia_binary`] / [`find_windows_kopia_binary`] /
9//!   [`find_windows_kopia_config`]: locate kopia and (on Windows) the per-user
10//!   repository config from KopiaUI's standard install locations.
11//! - [`linux_elevation`]: decide whether/how to elevate to the `kopia` system
12//!   user on Linux. Returns [`Elevation::Sudo`] when we're not the kopia user
13//!   and the system kopia install is present, [`Elevation::Direct`] when we
14//!   already have access, [`Elevation::Skip`] otherwise.
15//! - [`Snapshot`] and [`fetch_snapshots`]: deserialise `kopia snapshot list
16//!   --json` output into a typed shape.
17//! - [`SnapshotFilter`] / [`build_filter`]: in-process filtering of a snapshot
18//!   list by host, tag, path substring, and time window.
19//! - With the `cli` feature: [`SnapshotSelectorArgs`] (a `clap::Args`-derived
20//!   struct that consumer commands flatten into their own args) and
21//!   [`select_snapshot`] (a `dialoguer`-backed interactive picker).
22
23use std::{
24	collections::BTreeMap,
25	path::{Path, PathBuf},
26	process::Command,
27};
28
29use jiff::{Span, Timestamp};
30use miette::{Context as _, IntoDiagnostic as _, Result, miette};
31use serde::{Deserialize, Serialize};
32
33/// System user that owns the Linux kopia install.
34pub const LINUX_KOPIA_USER: &str = "kopia";
35
36/// Standard location of the system kopia repository config on Linux. Owned
37/// by the [`LINUX_KOPIA_USER`].
38pub const LINUX_KOPIA_CONFIG: &str = "/var/lib/kopia/.config/kopia/repository.config";
39
40/// Locate the kopia binary.
41///
42/// Order of preference:
43///   1. An explicit override path (`None` to skip)
44///   2. `kopia` (or `kopia.exe`) in `PATH`
45///   3. On Windows, well-known KopiaUI bundled binary locations
46pub fn find_kopia_binary(override_path: Option<&Path>) -> Option<PathBuf> {
47	if let Some(p) = override_path {
48		return Some(p.to_path_buf());
49	}
50	if let Some(p) = find_in_path("kopia") {
51		return Some(p);
52	}
53	if cfg!(windows) {
54		return find_windows_kopia_binary();
55	}
56	None
57}
58
59fn find_in_path(name: &str) -> Option<PathBuf> {
60	let exe = if cfg!(windows) {
61		format!("{name}.exe")
62	} else {
63		name.to_string()
64	};
65	let path = std::env::var_os("PATH")?;
66	for dir in std::env::split_paths(&path) {
67		let candidate = dir.join(&exe);
68		if candidate.is_file() {
69			return Some(candidate);
70		}
71	}
72	None
73}
74
75/// Look for the kopia binary bundled with KopiaUI on Windows.
76pub fn find_windows_kopia_binary() -> Option<PathBuf> {
77	let mut candidates: Vec<PathBuf> = Vec::new();
78	if let Ok(local) = std::env::var("LOCALAPPDATA") {
79		candidates.push(
80			Path::new(&local)
81				.join("Programs")
82				.join("KopiaUI")
83				.join("resources")
84				.join("server")
85				.join("kopia.exe"),
86		);
87	}
88	if let Ok(pf) = std::env::var("ProgramFiles") {
89		candidates.push(
90			Path::new(&pf)
91				.join("KopiaUI")
92				.join("resources")
93				.join("server")
94				.join("kopia.exe"),
95		);
96	}
97	if let Ok(pf86) = std::env::var("ProgramFiles(x86)") {
98		candidates.push(
99			Path::new(&pf86)
100				.join("KopiaUI")
101				.join("resources")
102				.join("server")
103				.join("kopia.exe"),
104		);
105	}
106	candidates.into_iter().find(|p| p.exists())
107}
108
109/// Standard per-user kopia repository config on Windows, used by KopiaUI.
110pub fn find_windows_kopia_config() -> Option<PathBuf> {
111	let appdata = std::env::var("APPDATA").ok()?;
112	let config = Path::new(&appdata).join("kopia").join("repository.config");
113	config.exists().then_some(config)
114}
115
116/// Current process's username (via `whoami`). `None` if `whoami` can't
117/// determine it (rare).
118pub fn current_username() -> Option<String> {
119	whoami::fallible::username().ok()
120}
121
122/// What to do about elevation on Linux when we want to run kopia.
123#[derive(Debug)]
124pub enum Elevation {
125	/// Run as the current user — either we're already the kopia user, or
126	/// there's no system kopia install (the operator's running their own).
127	Direct,
128	/// Wrap the kopia invocation in `sudo -u kopia --`. Used whenever we're
129	/// running as a different user and the system kopia install exists; if
130	/// `sudo` isn't allowed (no NOPASSWD rule, no TTY), the resulting kopia
131	/// invocation will fail and the caller surfaces that as a Skip.
132	Sudo,
133	/// We can't elevate. The caller should bail with a reason.
134	Skip(String),
135}
136
137/// Decide how to invoke kopia on Linux given the current user and whether
138/// the system kopia install is present (and accessible).
139///
140/// Logic:
141/// - If we're the kopia user, run directly.
142/// - Else, probe the system kopia config:
143///   - Not found (ENOENT): no system install. Run directly as current user;
144///     they're presumably running their own kopia under their own config.
145///   - Permission denied (EACCES): exists, owned by kopia user. Elevate via
146///     `sudo -u kopia`.
147///   - Readable: exists and we can read it (unusual mode). Run directly.
148#[cfg(target_os = "linux")]
149pub fn linux_elevation() -> Elevation {
150	let Some(user) = current_username() else {
151		return Elevation::Skip("could not determine current Unix username".into());
152	};
153
154	if user == LINUX_KOPIA_USER {
155		return Elevation::Direct;
156	}
157
158	match std::fs::metadata(LINUX_KOPIA_CONFIG) {
159		Ok(_) => Elevation::Direct,
160		Err(err) if err.kind() == std::io::ErrorKind::NotFound => Elevation::Direct,
161		Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => Elevation::Sudo,
162		Err(err) => Elevation::Skip(format!("checking {LINUX_KOPIA_CONFIG}: {err}")),
163	}
164}
165
166#[cfg(not(target_os = "linux"))]
167pub fn linux_elevation() -> Elevation {
168	Elevation::Direct
169}
170
171/// Build a `Command` that runs the kopia binary, elevated to the kopia user
172/// if the current platform/user requires it (Linux only).
173///
174/// On non-Linux platforms or when no elevation is needed, this is just
175/// `Command::new(kopia)`. On Linux with [`Elevation::Sudo`], it returns
176/// `sudo -u kopia -- <kopia>`. [`Elevation::Skip`] is propagated as an
177/// `Err` whose message is the Skip reason.
178pub fn build_kopia_command(kopia: &Path) -> Result<Command, String> {
179	if cfg!(target_os = "linux") {
180		match linux_elevation() {
181			Elevation::Direct => Ok(Command::new(kopia)),
182			Elevation::Sudo => {
183				let mut c = Command::new("sudo");
184				c.arg("-u").arg(LINUX_KOPIA_USER).arg("--").arg(kopia);
185				Ok(c)
186			}
187			Elevation::Skip(reason) => Err(reason),
188		}
189	} else {
190		Ok(Command::new(kopia))
191	}
192}
193
194/// A single kopia snapshot, as emitted by `kopia snapshot list --json`.
195#[derive(Debug, Clone, Deserialize, Serialize)]
196#[serde(rename_all = "camelCase")]
197pub struct Snapshot {
198	pub id: String,
199	pub source: SnapshotSource,
200	#[serde(default)]
201	pub description: String,
202	#[serde(default)]
203	pub start_time: Option<Timestamp>,
204	#[serde(default)]
205	pub end_time: Option<Timestamp>,
206	#[serde(default)]
207	pub tags: BTreeMap<String, String>,
208	#[serde(default)]
209	pub root_entry: Option<RootEntry>,
210}
211
212#[derive(Debug, Clone, Deserialize, Serialize)]
213pub struct SnapshotSource {
214	#[serde(default)]
215	pub host: String,
216	#[serde(default, rename = "userName")]
217	pub user_name: String,
218	#[serde(default)]
219	pub path: String,
220}
221
222#[derive(Debug, Clone, Deserialize, Serialize)]
223pub struct RootEntry {
224	#[serde(default, rename = "summ")]
225	pub summary: Option<DirSummary>,
226}
227
228#[derive(Debug, Clone, Deserialize, Serialize)]
229pub struct DirSummary {
230	#[serde(default, rename = "size")]
231	pub total_size: i64,
232	#[serde(default, rename = "files")]
233	pub total_files: i64,
234	#[serde(default, rename = "dirs")]
235	pub total_dirs: i64,
236}
237
238impl Snapshot {
239	/// Time at which the snapshot finished (or started, if `endTime` is missing).
240	pub fn taken_at(&self) -> Option<Timestamp> {
241		self.end_time.or(self.start_time)
242	}
243
244	/// Best-effort total size of the snapshot's contents.
245	pub fn total_size(&self) -> Option<i64> {
246		self.root_entry
247			.as_ref()
248			.and_then(|r| r.summary.as_ref())
249			.map(|s| s.total_size)
250	}
251}
252
253/// Filter criteria used by listing/restore/mount.
254#[derive(Debug, Default, Clone)]
255pub struct SnapshotFilter {
256	/// `None` means "any host". `Some(name)` filters source.host == name.
257	pub source_host: Option<String>,
258	/// All entries must match.
259	pub tags: BTreeMap<String, String>,
260	/// Source path must contain this substring (case-insensitive).
261	pub path_substr: Option<String>,
262	/// Snapshot's taken_at must be within this Span from now.
263	pub since: Option<Span>,
264	/// Cap to the N most recent snapshots after the other filters apply.
265	pub limit: Option<usize>,
266}
267
268impl SnapshotFilter {
269	/// Apply the filter to a list of snapshots, returning matches sorted
270	/// newest-first.
271	pub fn apply(&self, snapshots: &[Snapshot], now: Timestamp) -> Vec<Snapshot> {
272		let cutoff: Option<Timestamp> = self.since.and_then(|span| now.checked_sub(span).ok());
273
274		let path_substr_lc = self.path_substr.as_ref().map(|s| s.to_lowercase());
275
276		let mut matches: Vec<Snapshot> = snapshots
277			.iter()
278			.filter(|s| {
279				if let Some(host) = &self.source_host
280					&& s.source.host != *host
281				{
282					return false;
283				}
284				for (k, v) in &self.tags {
285					if s.tags.get(k) != Some(v) {
286						return false;
287					}
288				}
289				if let Some(needle) = &path_substr_lc
290					&& !s.source.path.to_lowercase().contains(needle)
291				{
292					return false;
293				}
294				if let Some(cutoff) = cutoff
295					&& s.taken_at().is_none_or(|t| t < cutoff)
296				{
297					return false;
298				}
299				true
300			})
301			.cloned()
302			.collect();
303
304		matches.sort_by_key(|s| std::cmp::Reverse(s.taken_at()));
305
306		if let Some(n) = self.limit {
307			matches.truncate(n);
308		}
309		matches
310	}
311}
312
313/// Parse a `key:value` tag spec from a `--tag` flag.
314pub fn parse_tag_kv(s: &str) -> Result<(String, String), String> {
315	s.split_once(':')
316		.map(|(k, v)| (k.trim().to_string(), v.trim().to_string()))
317		.filter(|(k, v)| !k.is_empty() && !v.is_empty())
318		.ok_or_else(|| format!("expected KEY:VALUE, got `{s}`"))
319}
320
321/// Build a [`SnapshotFilter`] from CLI-shaped inputs. `all = true` drops any
322/// source-host filter.
323pub fn build_filter(
324	all: bool,
325	source_host: Option<String>,
326	default_host: Option<String>,
327	tags: &[String],
328	path: Option<String>,
329	since: Option<&str>,
330	limit: Option<usize>,
331) -> Result<SnapshotFilter> {
332	let source_host = if all {
333		None
334	} else {
335		source_host.or(default_host)
336	};
337
338	let mut tag_map = BTreeMap::new();
339	for raw in tags {
340		let (k, v) = parse_tag_kv(raw).map_err(|e| miette!("invalid --tag: {e}"))?;
341		tag_map.insert(k, v);
342	}
343
344	let since = since
345		.map(|s| {
346			s.parse::<Span>()
347				.map_err(|e| miette!("invalid --since duration `{s}`: {e}"))
348		})
349		.transpose()?;
350
351	Ok(SnapshotFilter {
352		source_host,
353		tags: tag_map,
354		path_substr: path,
355		since,
356		limit,
357	})
358}
359
360/// Run `kopia snapshot list --json --all` and parse the result.
361///
362/// `bin` is expected to already be wrapped by [`build_kopia_command`] if
363/// elevation was needed — callers typically run that first and pass the
364/// resulting binary path here.
365pub fn fetch_snapshots(bin: &Path) -> Result<Vec<Snapshot>> {
366	let output = Command::new(bin)
367		.args(["snapshot", "list", "--json", "--all"])
368		.env("KOPIA_CHECK_FOR_UPDATES", "false")
369		.output()
370		.into_diagnostic()
371		.wrap_err_with(|| format!("invoking {}", bin.display()))?;
372	if !output.status.success() {
373		let stderr = String::from_utf8_lossy(&output.stderr);
374		return Err(miette!(
375			"kopia snapshot list exited {}: {}",
376			output.status,
377			stderr.trim()
378		));
379	}
380	serde_json::from_slice(&output.stdout)
381		.into_diagnostic()
382		.wrap_err("decoding kopia snapshot list JSON")
383}
384
385/// Kopia's manifest IDs are long hex. The short prefix is enough to identify
386/// a snapshot in a list; kopia restore/mount accept short prefixes too.
387pub fn short_id(id: &str) -> String {
388	const SHORT: usize = 16;
389	if id.len() <= SHORT {
390		id.to_string()
391	} else {
392		id.chars().take(SHORT).collect()
393	}
394}
395
396/// Format a snapshot timestamp for human display.
397pub fn format_taken(ts: Timestamp) -> String {
398	ts.strftime("%Y-%m-%d %H:%M").to_string()
399}
400
401/// Render a snapshot's tag map as a `k=v, k2=v2` string (sorted by key).
402pub fn format_tags(tags: &BTreeMap<String, String>) -> String {
403	tags.iter()
404		.map(|(k, v)| format!("{k}={v}"))
405		.collect::<Vec<_>>()
406		.join(", ")
407}
408
409/// One-line summary of a snapshot, suitable for an interactive picker.
410pub fn format_snapshot_line(snap: &Snapshot) -> String {
411	let taken = snap
412		.taken_at()
413		.map(format_taken)
414		.unwrap_or_else(|| "—".into());
415	let source = format!(
416		"{}@{}:{}",
417		snap.source.user_name, snap.source.host, snap.source.path
418	);
419	let size = snap
420		.total_size()
421		.map(human_bytes)
422		.unwrap_or_else(|| "—".into());
423	let tags = format_tags(&snap.tags);
424	if tags.is_empty() {
425		format!("{}  {taken}  {source}  {size}", short_id(&snap.id))
426	} else {
427		format!(
428			"{}  {taken}  {source}  {size}  [{tags}]",
429			short_id(&snap.id)
430		)
431	}
432}
433
434/// Human-readable size formatter.
435pub fn human_bytes(b: i64) -> String {
436	if b < 0 {
437		return "?".into();
438	}
439	const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB", "PB"];
440	let mut value = b as f64;
441	let mut unit = 0;
442	while value >= 1024.0 && unit < UNITS.len() - 1 {
443		value /= 1024.0;
444		unit += 1;
445	}
446	if unit == 0 {
447		format!("{}{}", b, UNITS[0])
448	} else {
449		format!("{value:.1}{}", UNITS[unit])
450	}
451}
452
453#[cfg(feature = "cli")]
454pub use cli::*;
455
456#[cfg(feature = "cli")]
457mod cli {
458	use clap::Args;
459	use dialoguer::Select;
460	use miette::{Context as _, IntoDiagnostic as _, Result, bail};
461
462	use super::*;
463
464	/// Shared snapshot-selection flags for commands that operate on a single
465	/// snapshot (`restore`, `mount`, …). Flatten into each command's args
466	/// struct via `#[command(flatten)]`.
467	#[derive(Debug, Clone, Args)]
468	pub struct SnapshotSelectorArgs {
469		/// Snapshot ID (full or short prefix). Without this or `--latest`,
470		/// the command opens an interactive picker.
471		#[arg(long, value_name = "ID")]
472		pub snapshot: Option<String>,
473
474		/// Use the newest matching snapshot without prompting.
475		///
476		/// Requires at least one of `--tag` or `--path` so the "newest" is
477		/// unambiguous — a kopia repo holds many kinds of snapshots and "the
478		/// latest one for this host" would otherwise pick whichever ran most
479		/// recently, regardless of what it was backing up.
480		#[arg(long, conflicts_with = "snapshot")]
481		pub latest: bool,
482
483		/// Filter: source host. Defaults to this host.
484		#[arg(long, value_name = "HOST", conflicts_with = "all")]
485		pub source_host: Option<String>,
486
487		/// Filter: list snapshots from every host.
488		#[arg(long, conflicts_with = "source_host")]
489		pub all: bool,
490
491		/// Filter: tag. Repeatable. Format: `key:value`.
492		#[arg(long = "tag", value_name = "KEY:VALUE", value_parser = parse_tag_arg)]
493		pub tags: Vec<String>,
494
495		/// Filter: source path substring (case-insensitive).
496		#[arg(long, value_name = "SUBSTR")]
497		pub path: Option<String>,
498
499		/// Filter: only snapshots within this duration (e.g. `24h`, `7d`).
500		#[arg(long, value_name = "DURATION")]
501		pub since: Option<String>,
502	}
503
504	fn parse_tag_arg(s: &str) -> Result<String, String> {
505		parse_tag_kv(s).map(|_| s.to_string())
506	}
507
508	impl SnapshotSelectorArgs {
509		/// Resolve to a single snapshot: explicit ID, `--latest` over
510		/// filters, or interactive picker over filters. `default_host` is
511		/// the host to filter on when neither `--source-host` nor `--all` is
512		/// given (typically the current hostname).
513		pub fn resolve(
514			&self,
515			bin: &std::path::Path,
516			default_host: Option<String>,
517			picker_prompt: &str,
518		) -> Result<Snapshot> {
519			use std::io::IsTerminal as _;
520
521			if let Some(id) = &self.snapshot {
522				return resolve_by_id(bin, id);
523			}
524
525			if self.latest && self.tags.is_empty() && self.path.is_none() {
526				bail!(
527					"--latest requires --tag or --path: a kopia repo has many kinds of snapshots, and the newest unfiltered one would pick an arbitrary type. Narrow with --tag (e.g. --tag area:postgres) or --path."
528				);
529			}
530
531			let snapshots = fetch_snapshots(bin)?;
532			let filter = build_filter(
533				self.all,
534				self.source_host.clone(),
535				default_host,
536				&self.tags,
537				self.path.clone(),
538				self.since.as_deref(),
539				None,
540			)?;
541			let matches = filter.apply(&snapshots, Timestamp::now());
542
543			if matches.is_empty() {
544				bail!("no snapshots match the given filters");
545			}
546
547			if self.latest {
548				return Ok(matches.into_iter().next().expect("non-empty"));
549			}
550
551			let interactive = std::io::stdout().is_terminal() && std::io::stdin().is_terminal();
552			if !interactive {
553				bail!(
554					"no snapshot specified and stdin/stdout isn't a TTY — pass --snapshot, or --latest (with --tag/--path) to pick the newest match"
555				);
556			}
557
558			select_snapshot(&matches, picker_prompt)
559		}
560	}
561
562	fn resolve_by_id(bin: &std::path::Path, id_query: &str) -> Result<Snapshot> {
563		let snapshots = fetch_snapshots(bin)?;
564		let matches: Vec<&Snapshot> = snapshots
565			.iter()
566			.filter(|s| s.id.starts_with(id_query))
567			.collect();
568		match matches.len() {
569			0 => bail!("no snapshot found with id starting `{id_query}`"),
570			1 => Ok(matches[0].clone()),
571			n => bail!("snapshot id `{id_query}` is ambiguous ({n} matches); use a longer prefix"),
572		}
573	}
574
575	/// Open an interactive picker over a list of snapshots, returning the
576	/// chosen one. Defaults to the first (newest) entry.
577	pub fn select_snapshot(snapshots: &[Snapshot], prompt: &str) -> Result<Snapshot> {
578		let items: Vec<String> = snapshots.iter().map(format_snapshot_line).collect();
579		let selection = Select::new()
580			.with_prompt(prompt)
581			.items(&items)
582			.default(0)
583			.interact()
584			.into_diagnostic()
585			.wrap_err("interactive picker failed")?;
586		Ok(snapshots[selection].clone())
587	}
588}
589
590#[cfg(test)]
591mod tests {
592	use jiff::ToSpan;
593
594	use super::*;
595
596	fn snapshot(id: &str, host: &str, path: &str, taken: Timestamp) -> Snapshot {
597		Snapshot {
598			id: id.into(),
599			source: SnapshotSource {
600				host: host.into(),
601				user_name: "kopia".into(),
602				path: path.into(),
603			},
604			description: String::new(),
605			start_time: Some(taken),
606			end_time: Some(taken),
607			tags: BTreeMap::new(),
608			root_entry: None,
609		}
610	}
611
612	#[test]
613	fn filter_by_host() {
614		let now = Timestamp::from_second(10_000_000).unwrap();
615		let snaps = vec![
616			snapshot("a", "host-1", "/data", now),
617			snapshot("b", "host-2", "/data", now),
618		];
619		let filter = SnapshotFilter {
620			source_host: Some("host-1".into()),
621			..Default::default()
622		};
623		let got = filter.apply(&snaps, now);
624		assert_eq!(got.len(), 1);
625		assert_eq!(got[0].id, "a");
626	}
627
628	#[test]
629	fn filter_by_tags_requires_all_to_match() {
630		let now = Timestamp::from_second(10_000_000).unwrap();
631		let mut tagged = snapshot("t", "h", "/data", now);
632		tagged.tags.insert("area".into(), "postgres".into());
633		tagged.tags.insert("type".into(), "ext4".into());
634		let snaps = vec![tagged, snapshot("u", "h", "/data", now)];
635
636		let mut tags = BTreeMap::new();
637		tags.insert("area".into(), "postgres".into());
638		tags.insert("type".into(), "ext4".into());
639		let filter = SnapshotFilter {
640			tags,
641			..Default::default()
642		};
643		let got = filter.apply(&snaps, now);
644		assert_eq!(got.len(), 1);
645		assert_eq!(got[0].id, "t");
646	}
647
648	#[test]
649	fn filter_path_substr_case_insensitive() {
650		let now = Timestamp::from_second(10_000_000).unwrap();
651		let snaps = vec![
652			snapshot("a", "h", r"C:\Program Files\PostgreSQL\15", now),
653			snapshot("b", "h", "/var/log/something", now),
654		];
655		let filter = SnapshotFilter {
656			path_substr: Some("postgresql".into()),
657			..Default::default()
658		};
659		let got = filter.apply(&snaps, now);
660		assert_eq!(got.len(), 1);
661		assert_eq!(got[0].id, "a");
662	}
663
664	#[test]
665	fn filter_since_drops_old_snapshots() {
666		let now = Timestamp::from_second(10_000_000).unwrap();
667		let snaps = vec![
668			snapshot("recent", "h", "/data", now - 1.hour()),
669			snapshot("old", "h", "/data", now - 30.hours()),
670		];
671		let filter = SnapshotFilter {
672			since: Some(24.hours()),
673			..Default::default()
674		};
675		let got = filter.apply(&snaps, now);
676		assert_eq!(got.len(), 1);
677		assert_eq!(got[0].id, "recent");
678	}
679
680	#[test]
681	fn filter_sorts_newest_first() {
682		let now = Timestamp::from_second(10_000_000).unwrap();
683		let snaps = vec![
684			snapshot("older", "h", "/data", now - 2.hours()),
685			snapshot("newer", "h", "/data", now - 1.hour()),
686		];
687		let filter = SnapshotFilter::default();
688		let got = filter.apply(&snaps, now);
689		assert_eq!(got[0].id, "newer");
690		assert_eq!(got[1].id, "older");
691	}
692
693	#[test]
694	fn filter_limit_truncates_after_sort() {
695		let now = Timestamp::from_second(10_000_000).unwrap();
696		let snaps = vec![
697			snapshot("a", "h", "/data", now - 3.hours()),
698			snapshot("b", "h", "/data", now - 1.hour()),
699			snapshot("c", "h", "/data", now - 2.hours()),
700		];
701		let filter = SnapshotFilter {
702			limit: Some(2),
703			..Default::default()
704		};
705		let got = filter.apply(&snaps, now);
706		assert_eq!(got.len(), 2);
707		assert_eq!(got[0].id, "b");
708		assert_eq!(got[1].id, "c");
709	}
710
711	#[test]
712	fn parse_tag_kv_accepts_simple() {
713		assert_eq!(
714			parse_tag_kv("area:postgres").unwrap(),
715			("area".into(), "postgres".into())
716		);
717	}
718
719	#[test]
720	fn parse_tag_kv_rejects_no_colon() {
721		assert!(parse_tag_kv("area-postgres").is_err());
722	}
723
724	#[test]
725	fn parse_tag_kv_rejects_empty_sides() {
726		assert!(parse_tag_kv(":value").is_err());
727		assert!(parse_tag_kv("key:").is_err());
728	}
729
730	#[test]
731	fn build_filter_all_drops_host() {
732		let filter =
733			build_filter(true, None, Some("ignored".into()), &[], None, None, None).unwrap();
734		assert!(filter.source_host.is_none());
735	}
736
737	#[test]
738	fn build_filter_default_host_used_when_not_overridden() {
739		let filter = build_filter(
740			false,
741			None,
742			Some("default-host".into()),
743			&[],
744			None,
745			None,
746			None,
747		)
748		.unwrap();
749		assert_eq!(filter.source_host.as_deref(), Some("default-host"));
750	}
751
752	#[test]
753	fn build_filter_explicit_host_beats_default() {
754		let filter = build_filter(
755			false,
756			Some("explicit".into()),
757			Some("default".into()),
758			&[],
759			None,
760			None,
761			None,
762		)
763		.unwrap();
764		assert_eq!(filter.source_host.as_deref(), Some("explicit"));
765	}
766
767	#[test]
768	fn build_filter_parses_since() {
769		let filter = build_filter(false, None, None, &[], None, Some("24h"), None).unwrap();
770		assert!(filter.since.is_some());
771	}
772
773	#[test]
774	fn build_filter_rejects_bad_since() {
775		let err =
776			build_filter(false, None, None, &[], None, Some("not-a-duration"), None).unwrap_err();
777		assert!(format!("{err}").contains("--since"));
778	}
779
780	#[test]
781	fn human_bytes_formats_units() {
782		assert_eq!(human_bytes(500), "500B");
783		assert_eq!(human_bytes(2 * 1024), "2.0KB");
784		assert_eq!(human_bytes(3 * 1024 * 1024 + 512 * 1024), "3.5MB");
785		assert_eq!(human_bytes(-1), "?");
786	}
787
788	#[test]
789	fn snapshot_taken_at_falls_back_to_start() {
790		let now = Timestamp::from_second(10_000_000).unwrap();
791		let mut snap = snapshot("a", "h", "/data", now);
792		snap.end_time = None;
793		assert_eq!(snap.taken_at(), Some(now));
794	}
795
796	#[test]
797	fn short_id_truncates_long_ids() {
798		assert_eq!(
799			short_id("kabcdef0123456789aaaaaaaaaaaaaaaa"),
800			"kabcdef012345678"
801		);
802	}
803
804	#[test]
805	fn short_id_passes_short_through() {
806		assert_eq!(short_id("k0000"), "k0000");
807	}
808
809	#[test]
810	fn format_tags_renders_sorted_kv_pairs() {
811		let mut tags = BTreeMap::new();
812		tags.insert("z".into(), "last".into());
813		tags.insert("a".into(), "first".into());
814		assert_eq!(format_tags(&tags), "a=first, z=last");
815	}
816
817	#[test]
818	fn format_tags_empty() {
819		let tags = BTreeMap::new();
820		assert_eq!(format_tags(&tags), "");
821	}
822
823	#[test]
824	fn format_snapshot_line_includes_id_source_and_tags() {
825		let now = Timestamp::from_second(10_000_000).unwrap();
826		let mut s = snapshot("kabc", "host-1", "/data", now);
827		s.tags.insert("area".into(), "postgres".into());
828		let line = format_snapshot_line(&s);
829		assert!(line.contains("kabc"));
830		assert!(line.contains("host-1"));
831		assert!(line.contains("/data"));
832		assert!(line.contains("area=postgres"));
833	}
834
835	#[test]
836	fn format_snapshot_line_omits_brackets_when_no_tags() {
837		let now = Timestamp::from_second(10_000_000).unwrap();
838		let s = snapshot("kabc", "host-1", "/data", now);
839		let line = format_snapshot_line(&s);
840		assert!(!line.contains("[]"));
841	}
842}