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}