Skip to main content

mana_core/sqlite/
freshness.rs

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}