1use anyhow::{Context, Result};
2use rusqlite::{params, Connection, OptionalExtension};
3use sha2::{Digest, Sha256};
4use std::fs;
5use std::path::Path;
6use std::time::{SystemTime, UNIX_EPOCH};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct SourceFileMetadata {
10 pub path: String,
11 pub unit_id: Option<String>,
12 pub kind: SourceFileKind,
13 pub hash: Option<String>,
14 pub mtime: Option<i64>,
15 pub size: Option<i64>,
16 pub status: SourceFileStatus,
17 pub error_kind: Option<String>,
18 pub error_message: Option<String>,
19 pub error_field: Option<String>,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum SourceFileKind {
24 Unit,
25 Archive,
26 Config,
27 Other,
28}
29
30impl SourceFileKind {
31 pub(crate) fn as_str(self) -> &'static str {
32 match self {
33 Self::Unit => "unit",
34 Self::Archive => "archive",
35 Self::Config => "config",
36 Self::Other => "other",
37 }
38 }
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum SourceFileStatus {
43 Valid,
44 InvalidParse,
45 InvalidSchema,
46 Missing,
47 Stale,
48 Archived,
49}
50
51impl SourceFileStatus {
52 pub(crate) fn as_str(self) -> &'static str {
53 match self {
54 Self::Valid => "valid",
55 Self::InvalidParse => "invalid_parse",
56 Self::InvalidSchema => "invalid_schema",
57 Self::Missing => "missing",
58 Self::Stale => "stale",
59 Self::Archived => "archived",
60 }
61 }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum Freshness {
66 Fresh,
67 Stale,
68 Missing,
69}
70
71pub fn record_source_file(conn: &Connection, metadata: &SourceFileMetadata) -> Result<()> {
72 conn.execute(
73 r#"
74 INSERT INTO source_files (
75 path, unit_id, kind, hash, mtime, size, indexed_at, status,
76 error_kind, error_message, error_field
77 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
78 ON CONFLICT(path) DO UPDATE SET
79 unit_id = excluded.unit_id,
80 kind = excluded.kind,
81 hash = excluded.hash,
82 mtime = excluded.mtime,
83 size = excluded.size,
84 indexed_at = excluded.indexed_at,
85 status = excluded.status,
86 error_kind = excluded.error_kind,
87 error_message = excluded.error_message,
88 error_field = excluded.error_field
89 "#,
90 params![
91 metadata.path,
92 metadata.unit_id,
93 metadata.kind.as_str(),
94 metadata.hash,
95 metadata.mtime,
96 metadata.size,
97 super::timestamp_now(),
98 metadata.status.as_str(),
99 metadata.error_kind,
100 metadata.error_message,
101 metadata.error_field,
102 ],
103 )?;
104 Ok(())
105}
106
107pub fn source_freshness(
108 conn: &Connection,
109 path: &str,
110 hash: Option<&str>,
111 mtime: Option<i64>,
112 size: Option<i64>,
113) -> Result<Freshness> {
114 let row = conn
115 .query_row(
116 "SELECT hash, mtime, size, status FROM source_files WHERE path = ?1",
117 [path],
118 |row| {
119 Ok((
120 row.get::<_, Option<String>>(0)?,
121 row.get::<_, Option<i64>>(1)?,
122 row.get::<_, Option<i64>>(2)?,
123 row.get::<_, String>(3)?,
124 ))
125 },
126 )
127 .optional()?;
128
129 let Some((stored_hash, stored_mtime, stored_size, status)) = row else {
130 return Ok(Freshness::Missing);
131 };
132
133 if status != SourceFileStatus::Valid.as_str() {
134 return Ok(Freshness::Stale);
135 }
136
137 if stored_hash.as_deref() == hash && stored_mtime == mtime && stored_size == size {
138 Ok(Freshness::Fresh)
139 } else {
140 Ok(Freshness::Stale)
141 }
142}
143
144pub fn source_file_metadata(path: &Path, unit_id: Option<String>) -> Result<SourceFileMetadata> {
145 source_file_metadata_with_kind(path, unit_id, SourceFileKind::Unit, SourceFileStatus::Valid)
146}
147
148pub(crate) fn source_file_metadata_with_kind(
149 path: &Path,
150 unit_id: Option<String>,
151 kind: SourceFileKind,
152 status: SourceFileStatus,
153) -> Result<SourceFileMetadata> {
154 let metadata = fs::metadata(path)
155 .with_context(|| format!("failed to read source file metadata: {}", path.display()))?;
156 let content = fs::read(path)
157 .with_context(|| format!("failed to read source file: {}", path.display()))?;
158
159 Ok(SourceFileMetadata {
160 path: path.display().to_string(),
161 unit_id,
162 kind,
163 hash: Some(content_hash(&content)),
164 mtime: metadata.modified().ok().and_then(system_time_to_unix_secs),
165 size: i64::try_from(metadata.len()).ok(),
166 status,
167 error_kind: None,
168 error_message: None,
169 error_field: None,
170 })
171}
172
173pub(crate) fn invalid_source_file_metadata(
174 path: &Path,
175 kind: SourceFileKind,
176 status: SourceFileStatus,
177 error_message: String,
178) -> Result<SourceFileMetadata> {
179 let metadata = fs::metadata(path)
180 .with_context(|| format!("failed to read source file metadata: {}", path.display()))?;
181 let content = fs::read(path)
182 .with_context(|| format!("failed to read source file: {}", path.display()))?;
183
184 Ok(SourceFileMetadata {
185 path: path.display().to_string(),
186 unit_id: None,
187 kind,
188 hash: Some(content_hash(&content)),
189 mtime: metadata.modified().ok().and_then(system_time_to_unix_secs),
190 size: i64::try_from(metadata.len()).ok(),
191 status,
192 error_kind: Some("parse".to_string()),
193 error_message: Some(error_message),
194 error_field: Some("frontmatter".to_string()),
195 })
196}
197
198fn content_hash(content: &[u8]) -> String {
199 let mut hasher = Sha256::new();
200 hasher.update(content);
201 format!("{:x}", hasher.finalize())
202}
203
204fn system_time_to_unix_secs(time: SystemTime) -> Option<i64> {
205 time.duration_since(UNIX_EPOCH)
206 .ok()
207 .and_then(|duration| i64::try_from(duration.as_secs()).ok())
208}