tiny_dc/
index.rs

1use std::{
2    collections::HashMap,
3    fs::File,
4    io::{BufRead, BufReader, BufWriter, Write},
5    path::PathBuf,
6    time::SystemTime,
7};
8
9pub const DEFAULT_INDEX_FILE_NAME: &str = ".tiny-dc";
10
11#[derive(Debug)]
12pub struct DirectoryIndexEntry {
13    /// Combined score based on frequence and recency
14    rank: f64,
15    /// Unix timestamp of the last access
16    last_accessed: u64,
17}
18
19impl DirectoryIndexEntry {
20    fn new() -> Self {
21        DirectoryIndexEntry {
22            rank: 0.0,
23            last_accessed: SystemTime::now()
24                .duration_since(SystemTime::UNIX_EPOCH)
25                .unwrap()
26                .as_secs(),
27        }
28    }
29
30    fn update(&mut self) {
31        let now = SystemTime::now()
32            .duration_since(SystemTime::UNIX_EPOCH)
33            .unwrap()
34            .as_secs();
35
36        self.last_accessed = now;
37
38        // Decay the previous rank slightly (1% decay) and add a fixed bonus for this new access.
39        // The factor 0.99 is used to slowly forget old accesses, while adding 1.0 ensures each
40        // access gives a boost.
41        self.rank = (self.rank * 0.99) + 1.0;
42    }
43
44    fn frecent_score(&self) -> f64 {
45        let now = SystemTime::now()
46            .duration_since(SystemTime::UNIX_EPOCH)
47            .unwrap()
48            .as_secs();
49
50        // Calculate the time since the last access
51        let dx = now - self.last_accessed;
52
53        // Calculate the frecent score, this was taken from rupa/z: https://github.com/rupa/z
54        //
55        // Breakdown of the scoring calculation:
56        // - `0.0001 * dx`: Small increase per second of inactivity.
57        // - `+ 1.0`: Ensures that when dx is zero, the term is 1.0, avoiding division by zero.
58        // - `+ 0.25`: Additional adjustment to calibrate the decay effect.
59        // - Division by this sum reduces the impact of the rank as time passes.
60        // - Multiplication by 3.75 scales the effect.
61        // - Finally, multiplying by 10000.0 amplifies the score to a more useful range.
62        10000.0 * self.rank * (3.75 / ((0.0001 * dx as f64 + 1.0) + 0.25))
63    }
64}
65
66/// A struct representing the directory index, which is a map of paths to their corresponding
67/// `DirectoryIndexEntry` objects. The index is stored on disk in a file specified by the user (or
68/// a default location see `DEFAULT_INDEX_FILE_NAME`).
69#[derive(Debug, Default)]
70pub struct DirectoryIndex {
71    path: PathBuf,
72    data: HashMap<PathBuf, DirectoryIndexEntry>,
73}
74
75impl DirectoryIndex {
76    /// Reads the index from disk, if it doesn't exist, creates a new one
77    pub fn try_from(path: PathBuf) -> anyhow::Result<Self> {
78        let file = if path.exists() {
79            // Open the file if it exists
80            File::open(&path)?
81        } else {
82            // Create the file if it doesn't exist
83            File::create_new(&path)?
84        };
85
86        let reader = BufReader::new(file);
87        let mut data = HashMap::new();
88
89        for line in reader.lines() {
90            let line = line?;
91            let parts: Vec<&str> = line.split('|').collect();
92
93            if parts.len() != 3 {
94                // Skip malformed lines
95                continue;
96            }
97
98            let path = PathBuf::from(parts[0]);
99            let rank: f64 = parts[1].parse().unwrap_or(0.0);
100            let last_accessed: u64 = parts[2].parse().unwrap_or(0);
101
102            let entry = DirectoryIndexEntry {
103                last_accessed,
104                rank,
105            };
106            data.insert(path.clone(), entry);
107        }
108
109        Ok(DirectoryIndex { path, data })
110    }
111
112    fn save_to_disk(&self) -> anyhow::Result<()> {
113        // Save the index to disk
114        let file = File::create(self.path.clone())?;
115        let mut writer = BufWriter::new(file);
116
117        for (path, entry) in &self.data {
118            writeln!(
119                writer,
120                "{}|{}|{}",
121                path.display(),
122                entry.rank,
123                entry.last_accessed
124            )?;
125        }
126
127        Ok(())
128    }
129
130    /// Pushes a new path to the index and saves it to disk. If the path doesn't exist it's a
131    /// no-op. If you push the same path multiple times, it will update the rank and last accessed
132    /// time.
133    pub fn push(&mut self, path: PathBuf) -> anyhow::Result<()> {
134        if !path.exists() {
135            // If the path doesn't exist, we don't want to add it to the index
136            return Ok(());
137        }
138
139        if let Some(entry) = self.data.get_mut(&path) {
140            // Entry exists, update it (to update the score and last accessed time)
141            entry.update();
142        } else {
143            let entry = DirectoryIndexEntry::new();
144            self.data.insert(path, entry);
145        }
146
147        self.save_to_disk()?;
148
149        Ok(())
150    }
151
152    /// Finds the top-ranked directory matching the query.
153    ///
154    /// If a non-existing path is found as a match, it will be removed from the index and the next
155    /// match will be returned until the index is exhausted. The index will be updated if a removal
156    /// occurs.
157    ///
158    /// The inner workings of this algo was heavily inspured by `rupa/z: https://github.com/rupa/z
159    pub fn z(&mut self, queries: Vec<String>) -> anyhow::Result<Option<PathBuf>> {
160        let mut matches = Vec::new();
161
162        for (path, stats) in &self.data {
163            let path_str = path.to_string_lossy();
164            let frecent_score = stats.frecent_score();
165
166            if queries.iter().all(|q| path_str.contains(q)) {
167                // Higher priority: case-sensitive match.
168                matches.push((path.clone(), frecent_score, 0))
169            } else if queries.iter().all(|q| path_str.to_lowercase().contains(q)) {
170                // Lower priority: case-insentitive match.
171                matches.push((path.clone(), frecent_score, 1));
172            }
173        }
174
175        if matches.is_empty() {
176            return Ok(None);
177        }
178
179        // Look for a candidate that is an ancestor of every match.
180        if let Some((ancestor, _, _)) = matches.iter().find(|(candidate, _, _)| {
181            matches
182                .iter()
183                .all(|(other, _, _)| other.starts_with(candidate))
184        }) {
185            return Ok(Some(ancestor.clone()));
186        }
187
188        // Fallback: sort by match priority, frecent score (high to low), and then by fewer path
189        // components.
190        matches.sort_by(|a, b| {
191            a.2.cmp(&b.2)
192                .then(b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal))
193                .then(a.0.components().count().cmp(&b.0.components().count()))
194        });
195
196        let mut is_index_updated = false;
197        let mut result = None;
198
199        for (path, _, _) in matches.iter() {
200            if path.exists() {
201                // If the path exists, break and return it
202                result = Some(path.clone());
203                break;
204            }
205
206            // If the path doesn't exist, remove it from the index
207            self.data.remove(path);
208            is_index_updated = true;
209        }
210
211        if is_index_updated {
212            // Save the index to disk if it was updated
213            self.save_to_disk()?;
214        }
215
216        Ok(result)
217    }
218
219    /// Returns all entries in the index ordered by their frecent score.
220    pub fn get_all_entries_ordered_by_rank(&self) -> Vec<PathBuf> {
221        let mut entries: Vec<_> = self.data.iter().collect();
222        entries.sort_by(|a, b| {
223            b.1.frecent_score()
224                .partial_cmp(&a.1.frecent_score())
225                .unwrap_or(std::cmp::Ordering::Equal)
226        });
227        entries.into_iter().map(|(path, _)| path.clone()).collect()
228    }
229}