Skip to main content

rover/cli/
cache.rs

1//! `rover cache <subcommand>` body.
2
3use 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
104/// Translate a shell-style glob to a SQL LIKE pattern using `\` as the escape.
105fn 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}