Skip to content

Commit 22a5159

Browse files
deseratclaude
andcommitted
[EPIC-3-1] vault: Implement vault directory structure and markdown utilities (Rust)
- What: Created complete vault filesystem utilities in Rust (sync-service) - Why: Foundation for markdown-based knowledge storage (EPIC-3 File Vault System) - How: 5 Rust modules using gray_matter, fs2, serde_yaml, sha2, tempfile Modules implemented: - template.rs: ItemMetadata struct + markdown template generation - parser.rs: YAML frontmatter parsing (gray_matter crate) - writer.rs: Atomic file operations (tempfile + rename) - hash.rs: SHA-256 content hashing for change detection - lock.rs: File locking with RAII pattern (fs2 crate) Acceptance criteria addressed: - [x] Vault module structure created (sync-service/src/vault/) - [x] 5 modules implemented with public API - [x] Dependencies added (gray_matter, fs2, uuid, chrono, tempfile) - [x] Comprehensive documentation (docs/vault-structure.md) - [x] 30 unit tests covering all modules - [x] Coverage: 86.08% (exceeds 80% target) - [x] Obsidian/VSCode compatibility (standard YAML frontmatter) - [x] Atomic writes prevent partial file corruption - [x] File locking prevents concurrent write conflicts Testing: - Unit tests: 30 passed, 1 ignored (multi-process lock timeout) - Line coverage: 96.13% - Region coverage: 86.08% (target: 80%+) - All modules: hash (87.5%), lock (67.9%), parser (91.7%), template (100%), writer (87.4%) Architecture: - Vault utilities designed as reusable library (used by both sync-service and cli) - All vault filesystem operations in Rust only (Python api-service never touches vault) - RAII patterns for automatic cleanup (locks) - Atomic file operations via tempfile (prevents race conditions) Documentation: - Complete vault structure guide (docs/vault-structure.md) - Category descriptions (Inbox, People, Projects, Ideas, Admin) - Frontmatter specification (required vs optional fields) - File naming conventions and sanitization rules - External tool compatibility notes (Obsidian, VSCode, Jekyll, Hugo) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent c40135d commit 22a5159

10 files changed

Lines changed: 2426 additions & 0 deletions

File tree

docs/dev/plans/EPIC-3-1-vault-markdown-utilities.md

Lines changed: 545 additions & 0 deletions
Large diffs are not rendered by default.

docs/vault-structure.md

Lines changed: 493 additions & 0 deletions
Large diffs are not rendered by default.

sync-service/Cargo.toml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ name = "noosphere-sync"
33
version = "0.1.0"
44
edition = "2021"
55

6+
[lib]
7+
name = "noosphere_sync"
8+
path = "src/lib.rs"
9+
610
[[bin]]
711
name = "noosphere-sync"
812
path = "src/main.rs"
@@ -35,3 +39,19 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
3539

3640
# Configuration
3741
config = "0.14"
42+
43+
# Vault Utilities
44+
# YAML frontmatter parsing
45+
gray_matter = "0.2"
46+
47+
# File locking
48+
fs2 = "0.4"
49+
50+
# UUID generation
51+
uuid = { version = "1.0", features = ["v4", "serde"] }
52+
53+
# Timestamps
54+
chrono = { version = "0.4", features = ["serde"] }
55+
56+
# Temporary files (atomic writes)
57+
tempfile = "3.0"

sync-service/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// Noosphere Sync Service Library
2+
// Provides reusable vault utilities for both sync-service and cli
3+
4+
pub mod vault;

sync-service/src/vault/hash.rs

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
// Content hashing module for detecting file changes
2+
3+
use anyhow::Result;
4+
use sha2::{Digest, Sha256};
5+
use std::fs;
6+
use std::path::Path;
7+
8+
/// Compute SHA-256 hash of string content
9+
///
10+
/// Returns a hexadecimal string representation of the hash.
11+
/// Used for detecting content changes and sync conflict detection.
12+
///
13+
/// # Arguments
14+
///
15+
/// * `content` - The string content to hash
16+
///
17+
/// # Returns
18+
///
19+
/// A 64-character hexadecimal string (SHA-256 hash)
20+
///
21+
/// # Example
22+
///
23+
/// ```
24+
/// use noosphere_sync::vault::hash::compute_content_hash;
25+
///
26+
/// let hash1 = compute_content_hash("Hello, world!");
27+
/// let hash2 = compute_content_hash("Hello, world!");
28+
/// assert_eq!(hash1, hash2); // Same content = same hash
29+
///
30+
/// let hash3 = compute_content_hash("Different content");
31+
/// assert_ne!(hash1, hash3); // Different content = different hash
32+
/// ```
33+
pub fn compute_content_hash(content: &str) -> String {
34+
let mut hasher = Sha256::new();
35+
hasher.update(content.as_bytes());
36+
format!("{:x}", hasher.finalize())
37+
}
38+
39+
/// Compute SHA-256 hash of a file's contents
40+
///
41+
/// Reads the entire file and computes its SHA-256 hash.
42+
/// Returns an error if the file cannot be read.
43+
///
44+
/// # Arguments
45+
///
46+
/// * `file_path` - Path to the file to hash
47+
///
48+
/// # Returns
49+
///
50+
/// A 64-character hexadecimal string (SHA-256 hash)
51+
///
52+
/// # Errors
53+
///
54+
/// Returns an error if:
55+
/// - File does not exist
56+
/// - File cannot be read
57+
/// - I/O error occurs
58+
///
59+
/// # Example
60+
///
61+
/// ```no_run
62+
/// use noosphere_sync::vault::hash::compute_file_hash;
63+
/// use std::path::Path;
64+
///
65+
/// let hash = compute_file_hash(Path::new("/tmp/test.md"))?;
66+
/// println!("File hash: {}", hash);
67+
/// # Ok::<(), anyhow::Error>(())
68+
/// ```
69+
pub fn compute_file_hash(file_path: &Path) -> Result<String> {
70+
let content = fs::read(file_path)?;
71+
let mut hasher = Sha256::new();
72+
hasher.update(&content);
73+
Ok(format!("{:x}", hasher.finalize()))
74+
}
75+
76+
/// Check if file content has changed since cached hash
77+
///
78+
/// Compares the current file hash with a previously cached hash.
79+
/// Returns `true` if the content has changed (or if no cached hash provided).
80+
///
81+
/// # Arguments
82+
///
83+
/// * `file_path` - Path to the file to check
84+
/// * `cached_hash` - Optional previously computed hash
85+
///
86+
/// # Returns
87+
///
88+
/// - `true` if content has changed (or no cached hash available)
89+
/// - `false` if content is unchanged
90+
///
91+
/// # Errors
92+
///
93+
/// Returns an error if file cannot be read.
94+
///
95+
/// # Example
96+
///
97+
/// ```no_run
98+
/// use noosphere_sync::vault::hash::{compute_file_hash, has_content_changed};
99+
/// use std::path::Path;
100+
///
101+
/// let path = Path::new("/tmp/test.md");
102+
/// let cached = compute_file_hash(path)?;
103+
///
104+
/// // ... file is modified externally (e.g., in Obsidian) ...
105+
///
106+
/// if has_content_changed(path, Some(&cached))? {
107+
/// println!("File was modified externally!");
108+
/// }
109+
/// # Ok::<(), anyhow::Error>(())
110+
/// ```
111+
pub fn has_content_changed(file_path: &Path, cached_hash: Option<&str>) -> Result<bool> {
112+
let current_hash = compute_file_hash(file_path)?;
113+
114+
match cached_hash {
115+
Some(cached) => Ok(current_hash != cached),
116+
None => Ok(true), // No cached hash, assume changed
117+
}
118+
}
119+
120+
#[cfg(test)]
121+
mod tests {
122+
use super::*;
123+
use std::io::Write;
124+
use tempfile::NamedTempFile;
125+
126+
#[test]
127+
fn test_compute_content_hash() {
128+
let content = "Hello, world!";
129+
let hash = compute_content_hash(content);
130+
131+
// SHA-256 hash should be 64 hex characters
132+
assert_eq!(hash.len(), 64);
133+
134+
// Hash should be deterministic
135+
let hash2 = compute_content_hash(content);
136+
assert_eq!(hash, hash2);
137+
}
138+
139+
#[test]
140+
fn test_hash_deterministic() {
141+
let content = "Test content for hashing";
142+
let hash1 = compute_content_hash(content);
143+
let hash2 = compute_content_hash(content);
144+
let hash3 = compute_content_hash(content);
145+
146+
assert_eq!(hash1, hash2);
147+
assert_eq!(hash2, hash3);
148+
}
149+
150+
#[test]
151+
fn test_different_content_different_hash() {
152+
let hash1 = compute_content_hash("Content A");
153+
let hash2 = compute_content_hash("Content B");
154+
155+
assert_ne!(hash1, hash2);
156+
}
157+
158+
#[test]
159+
fn test_compute_file_hash() -> Result<()> {
160+
// Create temporary file
161+
let mut temp_file = NamedTempFile::new()?;
162+
write!(temp_file, "Test file content")?;
163+
temp_file.flush()?;
164+
165+
// Compute hash
166+
let hash = compute_file_hash(temp_file.path())?;
167+
168+
// Should be 64 hex characters
169+
assert_eq!(hash.len(), 64);
170+
171+
// Should match hash of content
172+
let content_hash = compute_content_hash("Test file content");
173+
assert_eq!(hash, content_hash);
174+
175+
Ok(())
176+
}
177+
178+
#[test]
179+
fn test_has_content_changed_no_cache() -> Result<()> {
180+
let mut temp_file = NamedTempFile::new()?;
181+
write!(temp_file, "Initial content")?;
182+
temp_file.flush()?;
183+
184+
// No cached hash - should report changed
185+
assert!(has_content_changed(temp_file.path(), None)?);
186+
187+
Ok(())
188+
}
189+
190+
#[test]
191+
fn test_has_content_changed_unchanged() -> Result<()> {
192+
let mut temp_file = NamedTempFile::new()?;
193+
write!(temp_file, "Static content")?;
194+
temp_file.flush()?;
195+
196+
// Compute initial hash
197+
let cached_hash = compute_file_hash(temp_file.path())?;
198+
199+
// Content hasn't changed
200+
assert!(!has_content_changed(temp_file.path(), Some(&cached_hash))?);
201+
202+
Ok(())
203+
}
204+
205+
#[test]
206+
fn test_has_content_changed_modified() -> Result<()> {
207+
let mut temp_file = NamedTempFile::new()?;
208+
write!(temp_file, "Initial content")?;
209+
temp_file.flush()?;
210+
211+
// Compute initial hash
212+
let cached_hash = compute_file_hash(temp_file.path())?;
213+
214+
// Modify file
215+
temp_file.reopen()?;
216+
write!(temp_file, "Modified content")?;
217+
temp_file.flush()?;
218+
219+
// Content has changed
220+
assert!(has_content_changed(temp_file.path(), Some(&cached_hash))?);
221+
222+
Ok(())
223+
}
224+
225+
#[test]
226+
fn test_unicode_content() {
227+
// Test with unicode characters
228+
let content = "Hello, 世界! 🌍";
229+
let hash1 = compute_content_hash(content);
230+
let hash2 = compute_content_hash(content);
231+
232+
assert_eq!(hash1, hash2);
233+
assert_eq!(hash1.len(), 64);
234+
}
235+
}

0 commit comments

Comments
 (0)