1use std::path::{Path, PathBuf};
2
3use caretta_id::CarettaId;
4use chrono::Datelike as _;
5use indexmap::IndexMap;
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9use crate::error::{Error, Result};
10
11const ARCHELON_DIR: &str = ".archelon";
12
13#[derive(Debug, Clone)]
15pub struct Journal {
16 pub root: PathBuf,
18}
19
20impl Journal {
21 pub fn from_root(root: PathBuf) -> Result<Self> {
25 if root.join(ARCHELON_DIR).is_dir() {
26 Ok(Journal { root })
27 } else {
28 Err(Error::JournalNotFound)
29 }
30 }
31
32 pub fn find_from(start: &Path) -> Result<Self> {
36 let mut current = start.to_path_buf();
37 loop {
38 if current.join(ARCHELON_DIR).is_dir() {
39 return Ok(Journal { root: current });
40 }
41 if !current.pop() {
42 return Err(Error::JournalNotFound);
43 }
44 }
45 }
46
47 pub fn find() -> Result<Self> {
49 let cwd = std::env::current_dir()?;
50 Self::find_from(&cwd)
51 }
52
53 pub fn archelon_dir(&self) -> PathBuf {
55 self.root.join(ARCHELON_DIR)
56 }
57
58 pub fn entries_root(&self) -> Result<PathBuf> {
63 let config = self.config()?;
64 Ok(match config.journal.entries_dir {
65 Some(ref dir) if !dir.is_empty() => self.root.join(dir),
66 _ => self.root.clone(),
67 })
68 }
69
70 pub fn config(&self) -> Result<JournalConfig> {
73 let path = self.archelon_dir().join("config.toml");
74 if !path.exists() {
75 return Ok(JournalConfig::default());
76 }
77 let contents = std::fs::read_to_string(&path)?;
78 toml::from_str(&contents).map_err(|e| Error::InvalidConfig(e.to_string()))
79 }
80
81 pub fn find_entry_by_id(&self, id_prefix: &str) -> Result<PathBuf> {
87 let mut matches = Vec::new();
88 for dir in std::iter::once(self.entries_root()?).chain(self.year_subdirs()?) {
89 let Ok(rd) = std::fs::read_dir(&dir) else { continue };
90 for entry in rd.filter_map(|e| e.ok()) {
91 let p = entry.path();
92 if p.extension().and_then(|s| s.to_str()) == Some("md")
93 && p.file_stem()
94 .and_then(|s| s.to_str())
95 .is_some_and(|stem| stem.starts_with(id_prefix))
96 {
97 matches.push(p);
98 }
99 }
100 }
101
102 match matches.len() {
103 0 => Err(Error::EntryNotFound(id_prefix.to_owned())),
104 1 => Ok(matches.remove(0)),
105 n => Err(Error::AmbiguousId(id_prefix.to_owned(), n)),
106 }
107 }
108
109 pub fn collect_entries(&self) -> Result<Vec<PathBuf>> {
111 let entries_root = self.entries_root()?;
112 let mut paths = Vec::new();
113 collect_md_in(&entries_root, &mut paths)?;
114 for subdir in self.year_subdirs()? {
115 collect_md_in(&subdir, &mut paths)?;
116 }
117 paths.sort();
118 Ok(paths)
119 }
120
121 fn year_subdirs(&self) -> Result<Vec<PathBuf>> {
122 let root = self.entries_root()?;
123 let mut dirs = Vec::new();
124 for entry in std::fs::read_dir(&root)?.filter_map(|e| e.ok()) {
125 let p = entry.path();
126 if p.is_dir() {
127 if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
128 if !name.starts_with('.') && name.chars().all(|c| c.is_ascii_digit()) {
129 dirs.push(p);
130 }
131 }
132 }
133 }
134 Ok(dirs)
135 }
136
137 pub fn journal_id(&self) -> Result<Uuid> {
139 let config = self.config()?;
140 if let Some(id) = config.journal.id {
141 return Ok(id);
142 }
143 let id = Uuid::new_v4();
144 self.save_journal_id(id)?;
145 Ok(id)
146 }
147
148 fn save_journal_id(&self, id: Uuid) -> Result<()> {
149 let mut config = self.config()?;
150 config.journal.id = Some(id);
151 let path = self.archelon_dir().join("config.toml");
152 let content = toml::to_string_pretty(&config)
153 .map_err(|e| Error::InvalidConfig(e.to_string()))?;
154 std::fs::write(&path, content)?;
155 Ok(())
156 }
157
158 pub fn cache_dir(&self) -> Result<PathBuf> {
169 let id = self.journal_id()?;
170 Ok(xdg_cache_home().join("archelon").join(id.to_string()))
171 }
172
173}
174
175fn xdg_cache_home() -> PathBuf {
176 if let Ok(dir) = std::env::var("XDG_CACHE_HOME") {
177 if !dir.is_empty() {
178 return PathBuf::from(dir);
179 }
180 }
181 if let Ok(home) = std::env::var("HOME") {
182 return PathBuf::from(home).join(".cache");
183 }
184 std::env::temp_dir()
185}
186
187fn collect_md_in(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
188 let Ok(rd) = std::fs::read_dir(dir) else { return Ok(()) };
189 for entry in rd.filter_map(|e| e.ok()) {
190 let p = entry.path();
191 if p.extension().and_then(|s| s.to_str()) == Some("md") {
192 out.push(p);
193 }
194 }
195 Ok(())
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize, Default)]
202pub struct JournalConfig {
203 #[serde(default)]
204 pub journal: JournalSection,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct JournalSection {
209 #[serde(default)]
211 pub week_start: WeekStart,
212
213 #[serde(skip_serializing_if = "Option::is_none")]
218 pub id: Option<Uuid>,
219
220 #[serde(default)]
226 pub duplicate_title: DuplicateTitlePolicy,
227
228 #[serde(skip_serializing_if = "Option::is_none")]
232 pub entries_dir: Option<String>,
233
234 #[serde(flatten)]
236 pub extra: IndexMap<String, toml::Value>,
237}
238
239impl Default for JournalSection {
240 fn default() -> Self {
241 JournalSection {
242 week_start: WeekStart::Monday,
243 id: None,
244 duplicate_title: DuplicateTitlePolicy::default(),
245 entries_dir: None,
246 extra: IndexMap::new(),
247 }
248 }
249}
250
251#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
253#[serde(rename_all = "snake_case")]
254pub enum DuplicateTitlePolicy {
255 Allow,
257 #[default]
259 Warn,
260 Error,
262}
263
264#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
266#[serde(rename_all = "lowercase")]
267pub enum WeekStart {
268 #[default]
269 Monday,
270 Sunday,
271}
272
273pub fn slugify(title: &str) -> String {
285 title
286 .chars()
287 .map(|c| match c {
288 c if c.is_whitespace() => '_',
289 c if c.is_ascii_uppercase() => c.to_ascii_lowercase(),
290 c if c.is_ascii_alphanumeric() => c,
291 '-' | '_' | '.' => c,
292 c if !c.is_ascii() => c,
293 _ => '_',
294 })
295 .collect::<String>()
296 .split('_')
297 .filter(|s| !s.is_empty())
298 .collect::<Vec<_>>()
299 .join("_")
300}
301
302pub fn entry_filename(id: CarettaId, title: &str) -> String {
306 let slug = slugify(title);
307 if slug.is_empty() {
308 format!("{id}.md")
309 } else {
310 format!("{id}_{slug}.md")
311 }
312}
313
314pub fn new_entry_path(title: &str) -> (PathBuf, CarettaId) {
321 let id = CarettaId::now_unix();
322 let year = chrono::Local::now().year();
323 let path = PathBuf::from(year.to_string()).join(entry_filename(id, title));
324 (path, id)
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330
331 #[test]
332 fn slugify_basic() {
333 assert_eq!(slugify("My Example Entry"), "my_example_entry");
334 }
335
336 #[test]
337 fn slugify_preserves_hyphens() {
338 assert_eq!(slugify("2026-03-17"), "2026-03-17");
339 }
340
341 #[test]
342 fn slugify_preserves_japanese() {
343 assert_eq!(slugify("日本語タイトル"), "日本語タイトル");
344 }
345
346 #[test]
347 fn slugify_japanese_with_spaces() {
348 assert_eq!(slugify("今日の メモ"), "今日の_メモ");
349 }
350
351 #[test]
352 fn slugify_replaces_special_chars() {
353 assert_eq!(slugify("Hello, World! (2026)"), "hello_world_2026");
354 assert_eq!(slugify("a/b:c*d"), "a_b_c_d");
355 }
356
357 #[test]
358 fn slugify_trims_underscores() {
359 assert_eq!(slugify(" leading"), "leading");
360 }
361}