1use anyhow::Context;
4use jiff::Timestamp;
5use std::path::Path;
6
7use crate::config;
8use crate::storage::Db;
9use crate::storage::pages;
10
11pub enum Args {
12 List { limit: u64, offset: u64 },
13 Get { url: String },
14 Purge { pattern: String, all: bool },
15 Stats,
16}
17
18pub async fn run(args: Args, config_path: Option<&Path>) -> anyhow::Result<()> {
19 let _cfg = config::load_resolved(config_path).context("loading config")?;
20 let data_dir = crate::paths::data_dir();
21 std::fs::create_dir_all(&data_dir).context("creating data dir")?;
22 let db = Db::open(data_dir.join("rover.db"))
23 .await
24 .context("opening cache database")?;
25
26 match args {
27 Args::List { limit, offset } => list(&db, limit, offset).await,
28 Args::Get { url } => get(&db, &url).await,
29 Args::Purge { pattern, all } => purge(&db, &pattern, all).await,
30 Args::Stats => stats(&db).await,
31 }
32}
33
34async fn list(db: &Db, limit: u64, offset: u64) -> anyhow::Result<()> {
35 let entries = pages::list_paginated(db, offset, limit)
36 .await
37 .context("listing cache")?;
38 let now = Timestamp::now().as_second();
39
40 if entries.is_empty() {
41 println!("(cache is empty)");
42 return Ok(());
43 }
44
45 println!(
46 "{:<60} {:>10} {:>14} {:>14}",
47 "URL", "SIZE", "AGE", "EXPIRES_IN"
48 );
49 for e in entries {
50 let age_s = (now - e.fetched_at).max(0);
51 let expires_s = e.expires_at.map(|t| t - now).unwrap_or(0);
52 println!(
53 "{:<60} {:>10} {:>14} {:>14}",
54 truncate(&e.url, 58),
55 human_bytes(e.size_bytes as u64),
56 human_seconds(age_s),
57 if expires_s <= 0 {
58 "expired".to_string()
59 } else {
60 human_seconds(expires_s)
61 },
62 );
63 }
64 Ok(())
65}
66
67async fn get(db: &Db, url: &str) -> anyhow::Result<()> {
68 let hash = pages::url_hash(url);
69 if let Some(p) = pages::get_by_url_hash(db, &hash).await? {
70 print!("{}", p.extracted_md);
71 return Ok(());
72 }
73 if let Some(p) = pages::get_by_url(db, url).await? {
74 print!("{}", p.extracted_md);
75 return Ok(());
76 }
77 anyhow::bail!("not found in cache: {url}");
78}
79
80async fn purge(db: &Db, pattern: &str, all: bool) -> anyhow::Result<()> {
81 if pattern.is_empty() {
82 anyhow::bail!("pattern is empty; refusing to purge");
83 }
84 if !all && (pattern == "*" || pattern == "**") {
85 anyhow::bail!("refusing to purge entire cache without --all flag");
86 }
87 let like = glob_to_sql_like(pattern);
88 let n = pages::delete_by_url_like(db, &like)
89 .await
90 .context("purging cache")?;
91 println!("purged {n} entr{}", if n == 1 { "y" } else { "ies" });
92 Ok(())
93}
94
95async fn stats(db: &Db) -> anyhow::Result<()> {
96 let now = Timestamp::now().as_second();
97 let s = pages::stats(db, now).await.context("fetching stats")?;
98 println!("entries: {}", s.entry_count);
99 println!("total size: {}", human_bytes(s.total_extracted_bytes));
100 println!("expired: {}", s.expired_count);
101 Ok(())
102}
103
104fn glob_to_sql_like(pattern: &str) -> String {
106 let mut out = String::with_capacity(pattern.len() + 4);
107 for c in pattern.chars() {
108 match c {
109 '*' => out.push('%'),
110 '?' => out.push('_'),
111 '%' | '_' | '\\' => {
112 out.push('\\');
113 out.push(c);
114 }
115 other => out.push(other),
116 }
117 }
118 out
119}
120
121fn truncate(s: &str, max: usize) -> String {
122 if s.chars().count() <= max {
123 s.to_string()
124 } else {
125 let mut out: String = s.chars().take(max - 1).collect();
126 out.push('…');
127 out
128 }
129}
130
131fn human_bytes(n: u64) -> String {
132 const KIB: u64 = 1024;
133 const MIB: u64 = KIB * 1024;
134 if n >= MIB {
135 format!("{:.1} MiB", n as f64 / MIB as f64)
136 } else if n >= KIB {
137 format!("{:.1} KiB", n as f64 / KIB as f64)
138 } else {
139 format!("{n} B")
140 }
141}
142
143fn human_seconds(s: i64) -> String {
144 let s = s.max(0);
145 if s >= 86400 {
146 format!("{}d", s / 86400)
147 } else if s >= 3600 {
148 format!("{}h", s / 3600)
149 } else if s >= 60 {
150 format!("{}m", s / 60)
151 } else {
152 format!("{s}s")
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 #[test]
161 fn glob_translation() {
162 assert_eq!(glob_to_sql_like("https://x.com/*"), "https://x.com/%");
163 assert_eq!(glob_to_sql_like("page?"), "page_");
164 assert_eq!(glob_to_sql_like("100%"), "100\\%");
165 assert_eq!(glob_to_sql_like("under_score"), "under\\_score");
166 assert_eq!(glob_to_sql_like("back\\slash"), "back\\\\slash");
167 }
168
169 #[test]
170 fn human_bytes_formats() {
171 assert_eq!(human_bytes(500), "500 B");
172 assert_eq!(human_bytes(2048), "2.0 KiB");
173 assert_eq!(human_bytes(2 * 1024 * 1024), "2.0 MiB");
174 }
175
176 #[test]
177 fn human_seconds_formats() {
178 assert_eq!(human_seconds(45), "45s");
179 assert_eq!(human_seconds(120), "2m");
180 assert_eq!(human_seconds(7200), "2h");
181 assert_eq!(human_seconds(2 * 86400), "2d");
182 }
183}