sable-vault/src/utils.rs@main
raw
1use std::fs::File;
2
3use camino::Utf8Path;
4use jiff::Timestamp;
5
6#[cfg(feature = "git")]
7static CREATED: &str = r#"git log --reverse -1 --pretty="format:%cI" --"#;
8#[cfg(feature = "git")]
9static MODIFIED: &str = r#"git log -1 --pretty="format:%cI" --"#;
10
11#[cfg(feature = "git")]
12fn run(arg: &str) -> Result<Option<Timestamp>, Error> {
13 use std::process::Command;
14
15 if which::which("git").is_err() {
16 return Ok(None);
17 }
18
19 let (shell, cmd) = if cfg!(target_os = "windows") {
20 ("cmd", "/C")
21 } else {
22 ("sh", "-s")
23 };
24
25 tracing::debug!(cmd=?arg, "running git command");
26
27 let output = Command::new(shell)
28 .arg(cmd)
29 .arg(arg)
30 .output()
31 .map_err(Error::Command)?;
32
33 if !output.status.success() {
34 return Err(Error::CommandFailed(
35 String::from_utf8_lossy(&output.stdout).to_string(),
36 ));
37 }
38
39 let output = String::from_utf8(output.stdout).map_err(Error::OutputUtf8)?;
40 let output = output.trim();
41
42 if output.is_empty() || output.starts_with("fatal") {
44 return Ok(None);
45 }
46
47 let timestamp = output.parse().map_err(Error::TimestampParse)?;
48
49 Ok(Some(timestamp))
50}
51
52#[derive(Debug, miette::Diagnostic, thiserror::Error)]
53pub enum Error {
54 #[error("failed to open file")]
55 FileOpen(#[source] std::io::Error),
56 #[error("failed to get metadata of file")]
57 FileMetadata(#[source] std::io::Error),
58 #[error("failed to get timestamp of file")]
59 FileTime(#[source] std::io::Error),
60 #[error("failed to convert timestamp from system time of file")]
61 TimestampConvert(#[source] jiff::Error),
62
63 #[cfg(feature = "git")]
64 #[error("failed to run command against file")]
65 Command(#[source] std::io::Error),
66 #[cfg(feature = "git")]
67 #[error("failed to run command against file: {0}")]
68 CommandFailed(String),
69 #[cfg(feature = "git")]
70 #[error("failed to convert command output to utf-8 for file")]
71 OutputUtf8(#[source] std::string::FromUtf8Error),
72 #[cfg(feature = "git")]
73 #[error("failed to parse timestamp of file")]
74 TimestampParse(#[source] jiff::Error),
75}
76
77#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
81pub struct FileMetadata {
82 pub created: Timestamp,
86 pub modified: Timestamp,
90
91 pub git_created: Option<Timestamp>,
93 pub git_modified: Option<Timestamp>,
95}
96
97impl FileMetadata {
98 pub fn new(path: &Utf8Path) -> Result<Self, Error> {
99 let file = File::open(path.as_std_path()).map_err(Error::FileOpen)?;
100 let meta = file.metadata().map_err(Error::FileMetadata)?;
101
102 let created = meta.created().map_err(Error::FileTime)?;
103 let created = Timestamp::try_from(created).map_err(Error::TimestampConvert)?;
104
105 let modified = meta.modified().map_err(Error::FileTime)?;
106 let modified = Timestamp::try_from(modified).map_err(Error::TimestampConvert)?;
107
108 #[cfg(not(feature = "git"))]
109 let git_created = None;
110 #[cfg(not(feature = "git"))]
111 let git_modified = None;
112
113 #[cfg(feature = "git")]
114 let git_created = run(&format!("{CREATED} {path}"))?;
115 #[cfg(feature = "git")]
116 let git_modified = run(&format!("{MODIFIED} {path}"))?;
117
118 Ok(Self {
119 created,
120 modified,
121
122 git_created,
123 git_modified,
124 })
125 }
126}
127
128#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
129pub struct Heading {
130 pub level: usize,
131 pub id: String,
132 pub title: String,
133 pub children: Vec<Self>,
134}
135
136pub fn extract_headings(contents: &str) -> Vec<Heading> {
137 let mut headings = Vec::new();
138 let mut in_code_block = false;
139
140 for line in contents.lines() {
141 if line.trim_start().starts_with("```") {
142 in_code_block = !in_code_block;
143
144 continue;
145 }
146
147 if in_code_block {
148 continue;
149 }
150
151 if line.starts_with('#') {
152 let level = line.chars().take_while(|&c| c == '#').count();
153 let title = line[level..].trim().to_string();
154
155 headings.push(Heading {
156 level,
157 id: String::new(),
158 title,
159 children: vec![],
160 });
161 }
162 }
163
164 headings
165}
166
167pub fn make_table_of_contents(headings: Vec<Heading>) -> Vec<Heading> {
171 let mut toc = vec![];
172 for heading in headings {
173 if toc.is_empty() || !insert_into_parent(toc.iter_mut().last(), &heading) {
175 toc.push(heading);
176 }
177 }
178
179 toc
180}
181
182fn insert_into_parent(potential_parent: Option<&mut Heading>, heading: &Heading) -> bool {
185 match potential_parent {
186 None => {
187 false
189 }
190 Some(parent) => {
191 if heading.level <= parent.level {
192 return false;
194 }
195 if heading.level + 1 == parent.level {
196 parent.children.push(heading.clone());
198 return true;
199 }
200 if !insert_into_parent(parent.children.iter_mut().last(), heading) {
202 parent.children.push(heading.clone());
204 }
205 true
206 }
207 }
208}
209