Skip to main content

doing_taskpaper/
io.rs

1use std::{fs, path::Path};
2
3use doing_error::{Error, Result};
4
5use crate::{Document, serializer};
6
7/// Create a new doing file at `path` with a single default section.
8///
9/// If the file already exists and is non-empty, this is a no-op.
10/// Creates parent directories as needed.
11pub fn create_file(path: &Path, default_section: &str) -> Result<()> {
12  if fs::metadata(path).map(|m| m.len() > 0).unwrap_or(false) {
13    return Ok(());
14  }
15
16  if let Some(parent) = path.parent() {
17    fs::create_dir_all(parent)?;
18  }
19
20  fs::write(path, format!("{default_section}:\n"))?;
21  Ok(())
22}
23
24/// Read and parse a doing file from `path` into a `Document`.
25pub fn read_file(path: &Path) -> Result<Document> {
26  let content = fs::read_to_string(path)?;
27  Ok(Document::parse(&content))
28}
29
30/// Serialize and atomically write a `Document` to `path`.
31///
32/// Writes to a temporary file in the same directory first, then renames
33/// into place to prevent corruption from interrupted writes.
34/// Callers are responsible for sorting entries before calling this function.
35pub fn write_file(doc: &Document, path: &Path) -> Result<()> {
36  let content = serializer::serialize(doc);
37
38  let parent = path.parent().ok_or_else(|| {
39    Error::Io(std::io::Error::new(
40      std::io::ErrorKind::InvalidInput,
41      "path has no parent directory",
42    ))
43  })?;
44
45  let temp = tempfile::NamedTempFile::new_in(parent)?;
46  fs::write(temp.path(), &content)?;
47  temp.persist(path).map_err(|e| Error::Io(e.error))?;
48
49  Ok(())
50}
51
52#[cfg(test)]
53mod test {
54  use super::*;
55  use crate::{Entry, Note, Section, Tags};
56
57  mod create_file {
58    use pretty_assertions::assert_eq;
59
60    use super::*;
61
62    #[test]
63    fn it_creates_a_new_file_with_default_section() {
64      let dir = tempfile::tempdir().unwrap();
65      let path = dir.path().join("test.md");
66
67      create_file(&path, "Currently").unwrap();
68
69      let content = fs::read_to_string(&path).unwrap();
70      assert_eq!(content, "Currently:\n");
71    }
72
73    #[test]
74    fn it_creates_parent_directories() {
75      let dir = tempfile::tempdir().unwrap();
76      let path = dir.path().join("nested/deep/test.md");
77
78      create_file(&path, "Currently").unwrap();
79
80      assert!(path.exists());
81    }
82
83    #[test]
84    fn it_does_not_overwrite_existing_non_empty_file() {
85      let dir = tempfile::tempdir().unwrap();
86      let path = dir.path().join("test.md");
87      fs::write(&path, "Already here\n").unwrap();
88
89      create_file(&path, "Currently").unwrap();
90
91      let content = fs::read_to_string(&path).unwrap();
92      assert_eq!(content, "Already here\n");
93    }
94
95    #[test]
96    fn it_overwrites_empty_file() {
97      let dir = tempfile::tempdir().unwrap();
98      let path = dir.path().join("test.md");
99      fs::write(&path, "").unwrap();
100
101      create_file(&path, "Currently").unwrap();
102
103      let content = fs::read_to_string(&path).unwrap();
104      assert_eq!(content, "Currently:\n");
105    }
106  }
107
108  mod read_file {
109    use chrono::{Local, TimeZone};
110    use pretty_assertions::assert_eq;
111
112    use super::*;
113
114    #[test]
115    fn it_reads_and_parses_a_doing_file() {
116      let dir = tempfile::tempdir().unwrap();
117      let path = dir.path().join("test.md");
118      fs::write(
119        &path,
120        "Currently:\n\t- 2024-03-17 14:30 | Test task <aaaabbbbccccddddeeeeffffaaaabbbb>\n",
121      )
122      .unwrap();
123
124      let doc = read_file(&path).unwrap();
125
126      assert!(doc.has_section("Currently"));
127      let entries = doc.entries_in_section("Currently");
128      assert_eq!(entries.len(), 1);
129      assert_eq!(entries[0].title(), "Test task");
130      assert_eq!(
131        entries[0].date(),
132        Local.with_ymd_and_hms(2024, 3, 17, 14, 30, 0).unwrap()
133      );
134    }
135
136    #[test]
137    fn it_returns_error_for_missing_file() {
138      let dir = tempfile::tempdir().unwrap();
139      let path = dir.path().join("nonexistent.md");
140
141      let result = read_file(&path);
142
143      assert!(result.is_err());
144    }
145  }
146
147  mod write_file {
148    use chrono::{Local, TimeZone};
149    use pretty_assertions::assert_eq;
150
151    use super::*;
152
153    #[test]
154    fn it_writes_document_to_file() {
155      let dir = tempfile::tempdir().unwrap();
156      let path = dir.path().join("test.md");
157      let mut doc = Document::new();
158      let mut section = Section::new("Currently");
159      section.add_entry(Entry::new(
160        Local.with_ymd_and_hms(2024, 3, 17, 14, 30, 0).unwrap(),
161        "Test task",
162        Tags::new(),
163        Note::new(),
164        "Currently",
165        Some("aaaabbbbccccddddeeeeffffaaaabbbb"),
166      ));
167      doc.add_section(section);
168
169      write_file(&doc, &path).unwrap();
170
171      let content = fs::read_to_string(&path).unwrap();
172      assert!(content.contains("Currently:"));
173      assert!(content.contains("Test task"));
174    }
175
176    #[test]
177    fn it_round_trips_through_read_and_write() {
178      let dir = tempfile::tempdir().unwrap();
179      let path = dir.path().join("test.md");
180      let original = "\
181Currently:
182\t- 2024-03-17 14:30 | Working on feature @coding <aaaabbbbccccddddeeeeffffaaaabbbb>
183\t\tA note about the work
184Archive:
185\t- 2024-03-16 10:00 | Old task @done(2024-03-16 11:00) <bbbbccccddddeeeeffffaaaabbbbcccc>";
186      fs::write(&path, original).unwrap();
187
188      let doc = read_file(&path).unwrap();
189      write_file(&doc, &path).unwrap();
190
191      let content = fs::read_to_string(&path).unwrap();
192      assert_eq!(content, original);
193    }
194  }
195}