Skip to main content

kimun_notes/settings/
history.rs

1//! A workspace's open-file history: the notes most recently opened in it,
2//! newest first.
3//!
4//! The counterpart of [`IndexFile`](kimun_core::IndexFile) for the other
5//! per-workspace artifact — same shape (the type owns its own naming, and
6//! moving or deleting a workspace goes through it), but it lives here rather
7//! than in core because "which notes did I look at last" is a TUI concern.
8//! Core knows nothing about it.
9
10use std::io::{BufRead, BufReader};
11
12use kimun_core::nfs::VaultPath;
13use kimun_core::system::{self, SystemError, SystemPath};
14
15pub const LAST_PATH_HISTORY_SIZE: usize = 50;
16
17/// Extension of a workspace's history file.
18const HISTORY_FILE_EXT: &str = "txt";
19
20/// A workspace's history file on this machine.
21///
22/// Plain text, one [`VaultPath`] per line. Non-critical by design: a missing,
23/// unreadable or half-written file costs the user an ordering, not a note, so
24/// reads never fail — they return what they could parse.
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct HistoryFile {
27    path: SystemPath,
28}
29
30impl HistoryFile {
31    /// The history for `workspace_name` inside `dir`.
32    ///
33    /// The naming rule lives here, so a workspace resolves to the same file
34    /// from every caller — the same reason `IndexFile::in_dir` exists.
35    /// `workspace_name` must already be a valid filename.
36    pub fn in_dir(dir: &SystemPath, workspace_name: &str) -> Self {
37        Self {
38            path: dir.join(format!("{workspace_name}.{HISTORY_FILE_EXT}")),
39        }
40    }
41
42    /// The file's path, for callers that need to report it.
43    pub fn path(&self) -> &SystemPath {
44        &self.path
45    }
46
47    /// Whether the file exists. A workspace that has never been opened has no
48    /// history, which is not an error.
49    pub fn exists(&self) -> bool {
50        self.path.exists()
51    }
52
53    /// The stored paths, newest first. Missing file, unreadable file or
54    /// malformed lines all yield what could be read; failures are logged.
55    pub fn load(&self) -> Vec<VaultPath> {
56        let file = match std::fs::File::open(&self.path) {
57            Ok(f) => f,
58            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
59            Err(e) => {
60                tracing::warn!("failed to open history file {}: {}", self.path, e);
61                return Vec::new();
62            }
63        };
64        BufReader::new(file)
65            .lines()
66            .map_while(Result::ok)
67            .filter_map(|line| {
68                let candidate = VaultPath::new(line.trim());
69                (!candidate.to_string().is_empty()).then_some(candidate)
70            })
71            .collect()
72    }
73
74    /// Moves `path` to the front, dropping any earlier occurrence and
75    /// truncating to [`LAST_PATH_HISTORY_SIZE`]. A no-op when it is already at
76    /// the front — the common case of reopening the note you are editing.
77    pub fn push(&self, path: &VaultPath) -> Result<(), SystemError> {
78        // Dedup with `is_like` (ignores relative/absolute form) so a note
79        // reopened via different-form paths isn't stored twice; the entry is
80        // stored in whatever form it arrived, to avoid rewriting existing
81        // history files.
82        let mut existing = self.load();
83        if existing.first().is_some_and(|f| f.is_like(path)) {
84            return Ok(());
85        }
86        existing.retain(|p| !p.is_like(path));
87        existing.insert(0, path.clone());
88        existing.truncate(LAST_PATH_HISTORY_SIZE);
89        self.write(&existing)
90    }
91
92    /// Replaces the file's contents with `paths`.
93    ///
94    /// Atomic, because this rewrites the whole file on every note the user
95    /// opens: a crash mid-write would otherwise leave a truncated history. The
96    /// tmp-then-rename recipe itself lives in `system` — it is the same one
97    /// every host-scoped writer needs, and getting it wrong is silent.
98    pub fn write(&self, paths: &[VaultPath]) -> Result<(), SystemError> {
99        let mut body = String::new();
100        for path in paths {
101            body.push_str(&path.to_string());
102            body.push('\n');
103        }
104        system::replace_atomically(self.path.as_path(), body.as_bytes())
105    }
106
107    /// Moves this history file to `dest`, for a workspace being renamed.
108    ///
109    /// Refuses rather than overwrites: an existing destination aborts before
110    /// anything moves. A source that does not exist is a no-op — a workspace
111    /// with no history still renames.
112    pub fn move_to(&self, dest: &HistoryFile) -> Result<(), SystemError> {
113        if !self.exists() {
114            return Ok(());
115        }
116        if dest.exists() {
117            return Err(SystemError::AlreadyExists {
118                path: dest.path.to_string(),
119            });
120        }
121        system::move_file(self.path.as_path(), dest.path.as_path())
122    }
123
124    /// Deletes this history file. A missing file is not an error.
125    pub fn remove(&self) -> Result<(), SystemError> {
126        system::remove_file(self.path.as_path())
127    }
128}
129
130impl std::fmt::Display for HistoryFile {
131    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132        write!(f, "{}", self.path)
133    }
134}