diff --git a/Cargo.lock b/Cargo.lock
index 7bc2f04..9ecee80 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -551,6 +551,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d"
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
[[package]]
name = "errno"
version = "0.3.13"
@@ -729,6 +735,12 @@ version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
+[[package]]
+name = "hashbrown"
+version = "0.15.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5"
+
[[package]]
name = "heck"
version = "0.5.0"
@@ -783,6 +795,16 @@ dependencies = [
"cc",
]
+[[package]]
+name = "indexmap"
+version = "2.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
[[package]]
name = "indicatif"
version = "0.17.11"
@@ -1476,6 +1498,15 @@ dependencies = [
"serde",
]
+[[package]]
+name = "serde_spanned"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "serial_test"
version = "3.2.0"
@@ -1645,6 +1676,48 @@ dependencies = [
"syn",
]
+[[package]]
+name = "toml"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
+dependencies = [
+ "indexmap",
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_edit",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.22.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
+dependencies = [
+ "indexmap",
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_write",
+ "winnow",
+]
+
+[[package]]
+name = "toml_write"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
+
[[package]]
name = "tracing"
version = "0.1.41"
@@ -1709,7 +1782,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "vaultify"
-version = "0.2.7"
+version = "0.3.0"
dependencies = [
"aes-gcm",
"anyhow",
@@ -1737,6 +1810,7 @@ dependencies = [
"tempfile",
"thiserror",
"tokio",
+ "toml",
"zeroize",
]
@@ -2236,6 +2310,15 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
+[[package]]
+name = "winnow"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "wit-bindgen-rt"
version = "0.39.0"
diff --git a/Cargo.toml b/Cargo.toml
index 83281a3..ef4e7cd 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "vaultify"
-version = "0.2.7"
+version = "0.3.0"
edition = "2021"
authors = ["vaultify contributors"]
description = "A secure, file-based secrets vault with interactive CLI"
@@ -42,6 +42,7 @@ atty = "0.2"
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
+toml = { version = "0.8", features = ["preserve_order"] }
# Error handling
thiserror = "1.0"
diff --git a/README.md b/README.md
index 3ee8c9c..d0471e7 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ A secure, file-based password manager with hierarchical organization. Written in
- 🔐 **Per-item encryption** with Argon2id + AES-256-GCM
- 📁 **Hierarchical organization** of secrets
-- 📝 **Markdown-based** vault format for easy versioning
+- 📝 **TOML-based** vault format for easy editing and versioning
- 🔍 **Fast filtering** of entries
- 📋 **Clipboard integration** with automatic clearing
- 🚀 **Interactive and CLI modes**
@@ -58,7 +58,7 @@ cargo build --release
vaultify init
```
-This creates a `vault.md` file in the current directory.
+This creates a `vault.toml` file in the current directory.
### Add a secret
@@ -134,26 +134,29 @@ vaultify> exit
## Vault Format
-Vaults are stored as markdown files with encrypted content:
+Vaults are stored as TOML files with encrypted content:
-```markdown
-# root
+```toml
+version = "v0.3"
+modified = "2025-01-17T10:00:00Z"
-## personal
-
-Personal accounts
-
-
+[personal]
+description = "Personal accounts"
+encrypted = ""
+salt = ""
-### personal/email
-
-Email accounts
-
-
-base64-encoded-encrypted-content
-
+[personal.email]
+description = "Email accounts"
+encrypted = "base64-encoded-encrypted-content"
+salt = "base64-encoded-salt"
```
+### Key Features:
+- **Insertion order preserved**: Entries maintain their original order
+- **Smart group insertion**: New entries are added at the end of their group
+- **Native TOML format**: Clean, readable structure with dotted key notation
+- **Flexible parsing**: Parent entries are created automatically
+
## Security
- **Encryption**: Each entry is encrypted with Argon2id + AES-256-GCM
diff --git a/src/cli.rs b/src/cli.rs
index d90f966..b7ab472 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -20,7 +20,7 @@ pub struct Cli {
long,
global = true,
env = "VAULT_FILE",
- help = "Path to vault file (default: searches for vault.md)"
+ help = "Path to vault file (default: searches for vault.toml)"
)]
pub file: Option,
@@ -56,7 +56,7 @@ pub enum Commands {
/// Add a new secret to the vault
Add {
- /// Secret scope (e.g., personal/email/gmail)
+ /// Secret scope (e.g., personal.email.gmail)
scope: String,
/// Description of the secret
@@ -158,11 +158,11 @@ pub enum Commands {
/// Decrypt GPG-encrypted vault file
GpgDecrypt {
- /// Input file (default: vault.md.gpg or vault.md.asc)
+ /// Input file (default: vault.toml.gpg or vault.toml.asc)
#[arg(short, long)]
input: Option,
- /// Output file (default: vault.md)
+ /// Output file (default: vault.toml)
#[arg(short = 'O', long = "output-file")]
output_file: Option,
},
@@ -241,7 +241,7 @@ impl Cli {
let vault_path = if let Some(path) = &self.file {
path.clone()
} else {
- PathBuf::from("vault.md")
+ PathBuf::from("vault.toml")
};
// Check if vault already exists
@@ -259,9 +259,10 @@ impl Cli {
}
}
- // Create vault file
- let content = "# root \n";
- fs::write(&vault_path, content)?;
+ // Create vault file with TOML format
+ let content = "version = \"v0.3\"\ncreated = \"{}\"\n";
+ let now = chrono::Utc::now().to_rfc3339();
+ fs::write(&vault_path, content.replace("{}", &now))?;
// Set proper permissions on Unix
#[cfg(unix)]
@@ -334,36 +335,31 @@ impl Cli {
let scopes: Vec = result.entries.iter().map(|e| e.scope.clone()).collect();
let tree_lines = utils::format_tree(&scopes);
- for line in tree_lines.iter() {
- // Find the corresponding entry by checking if the line ends with the last part of the scope
- if let Some(entry) = result.entries.iter().find(|e| {
- let scope_parts: Vec<&str> = e.scope.split('/').collect();
- if let Some(last_part) = scope_parts.last() {
- line.ends_with(last_part)
- } else {
- false
- }
- }) {
- let desc_lines: Vec<&str> = entry.description.lines().collect();
- let first_line = desc_lines.first().copied().unwrap_or("");
+ for (i, line) in tree_lines.iter().enumerate() {
+ // Find the corresponding entry by matching the scope from the original scopes list
+ if i < scopes.len() {
+ if let Some(entry) = result.entries.iter().find(|e| e.scope == scopes[i]) {
+ let desc_lines: Vec<&str> = entry.description.lines().collect();
+ let first_line = desc_lines.first().copied().unwrap_or("");
- if !entry.has_content {
- println!("{} {} - {}", line, "[empty]".yellow(), first_line);
- } else {
- println!("{} - {}", line, first_line);
- }
+ if !entry.has_content {
+ println!("{} {} - {}", line, "[empty]".yellow(), first_line);
+ } else {
+ println!("{line} - {first_line}");
+ }
- // Print additional description lines with appropriate indentation
- if desc_lines.len() > 1 {
- // Calculate indentation based on tree line
- let indent_len = line.len() + 3; // +3 for " - "
- let indent = " ".repeat(indent_len);
- for desc_line in desc_lines.iter().skip(1) {
- println!("{}{}", indent, desc_line);
+ // Print additional description lines with appropriate indentation
+ if desc_lines.len() > 1 {
+ // Calculate indentation based on tree line
+ let indent_len = line.len() + 3; // +3 for " - "
+ let indent = " ".repeat(indent_len);
+ for desc_line in desc_lines.iter().skip(1) {
+ println!("{indent}{desc_line}");
+ }
}
+ } else {
+ println!("{line}");
}
- } else {
- println!("{line}");
}
}
} else {
@@ -394,7 +390,7 @@ impl Cli {
// Print additional description lines indented
for line in desc_lines.iter().skip(1) {
- println!(" {}", line);
+ println!(" {line}");
}
}
}
@@ -579,8 +575,8 @@ impl Cli {
} else {
// Try to find encrypted vault
let vault_path = self.get_vault_file()?;
- let gpg_path = vault_path.with_extension("md.gpg");
- let asc_path = vault_path.with_extension("md.asc");
+ let gpg_path = vault_path.with_extension("toml.gpg");
+ let asc_path = vault_path.with_extension("toml.asc");
if gpg_path.exists() {
gpg_path
@@ -588,7 +584,7 @@ impl Cli {
asc_path
} else {
return Err(VaultError::Other(
- "No encrypted vault file found (vault.md.gpg or vault.md.asc)".to_string(),
+ "No encrypted vault file found (vault.toml.gpg or vault.toml.asc)".to_string(),
));
}
};
@@ -680,16 +676,18 @@ mod tests {
#[test]
fn test_validate_scope_name() {
- assert!(utils::validate_scope_name("personal/email"));
- assert!(utils::validate_scope_name("work/vpn"));
+ assert!(utils::validate_scope_name("personal.email"));
+ assert!(utils::validate_scope_name("work.vpn"));
assert!(!utils::validate_scope_name(""));
- assert!(!utils::validate_scope_name("personal/."));
- assert!(!utils::validate_scope_name("personal/.."));
+ assert!(!utils::validate_scope_name("personal.."));
+ assert!(!utils::validate_scope_name("personal..."));
+ assert!(!utils::validate_scope_name(".personal"));
+ assert!(!utils::validate_scope_name("personal."));
}
#[test]
fn test_parse_scope_path() {
- let parts = utils::parse_scope_path("personal/email/gmail");
+ let parts = utils::parse_scope_path("personal.email.gmail");
assert_eq!(parts, vec!["personal", "email", "gmail"]);
}
}
diff --git a/src/error.rs b/src/error.rs
index eddc780..479fd0c 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -48,9 +48,6 @@ pub enum VaultError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
- #[error("Parse error: {0}")]
- Parse(#[from] crate::parser::ParseError),
-
#[error("Crypto error: {0}")]
Crypto(#[from] crate::crypto::CryptoError),
diff --git a/src/gpg.rs b/src/gpg.rs
index 47517b5..8288966 100644
--- a/src/gpg.rs
+++ b/src/gpg.rs
@@ -181,9 +181,9 @@ mod tests {
#[test]
fn test_is_gpg_file() {
- assert!(GpgOperations::is_gpg_file(Path::new("vault.md.gpg")));
- assert!(GpgOperations::is_gpg_file(Path::new("vault.md.asc")));
- assert!(!GpgOperations::is_gpg_file(Path::new("vault.md")));
+ assert!(GpgOperations::is_gpg_file(Path::new("vault.toml.gpg")));
+ assert!(GpgOperations::is_gpg_file(Path::new("vault.toml.asc")));
+ assert!(!GpgOperations::is_gpg_file(Path::new("vault.toml")));
}
#[test]
diff --git a/src/interactive.rs b/src/interactive.rs
index 91b8dc6..1c96f1c 100644
--- a/src/interactive.rs
+++ b/src/interactive.rs
@@ -212,16 +212,23 @@ impl InteractiveVault {
let scopes: Vec = result.entries.iter().map(|e| e.scope.clone()).collect();
let tree_lines = utils::format_tree(&scopes);
- for line in tree_lines {
- // Find if this line represents an entry
- if let Some(entry) = result.entries.iter().find(|e| line.contains(&e.scope)) {
- if !entry.has_content {
- println!(" {} {} - {}", line, "[empty]".yellow(), entry.description);
+ for (i, line) in tree_lines.iter().enumerate() {
+ // Find the corresponding entry by matching the scope from the original scopes list
+ if i < scopes.len() {
+ if let Some(entry) = result.entries.iter().find(|e| e.scope == scopes[i]) {
+ if !entry.has_content {
+ println!(
+ " {} {} - {}",
+ line,
+ "[empty]".yellow(),
+ entry.description
+ );
+ } else {
+ println!(" {} - {}", line, entry.description);
+ }
} else {
- println!(" {} - {}", line, entry.description);
+ println!(" {line}");
}
- } else {
- println!(" {line}");
}
}
}
@@ -521,8 +528,8 @@ impl InteractiveVault {
/// Decrypt GPG-encrypted vault file (interactive).
fn gpg_decrypt_interactive(&self) -> Result<()> {
// Try to find encrypted vault
- let gpg_path = self.vault_path.with_extension("md.gpg");
- let asc_path = self.vault_path.with_extension("md.asc");
+ let gpg_path = self.vault_path.with_extension("toml.gpg");
+ let asc_path = self.vault_path.with_extension("toml.asc");
let encrypted_path = if gpg_path.exists() && asc_path.exists() {
// Both exist, ask which one to use
@@ -546,7 +553,7 @@ impl InteractiveVault {
asc_path
} else {
return Err(VaultError::Other(
- "No encrypted vault file found (vault.md.gpg or vault.md.asc)".to_string(),
+ "No encrypted vault file found (vault.toml.gpg or vault.toml.asc)".to_string(),
));
};
@@ -596,10 +603,10 @@ mod tests {
#[test]
fn test_interactive_vault_creation() {
let dir = tempdir().unwrap();
- let vault_path = dir.path().join("test_vault.md");
+ let vault_path = dir.path().join("test_vault.toml");
// Create a test vault file
- std::fs::write(&vault_path, "# root \n").unwrap();
+ std::fs::write(&vault_path, "version = \"v0.3\"\n").unwrap();
// Create interactive vault
let vault = InteractiveVault::new(vault_path.clone());
diff --git a/src/lib.rs b/src/lib.rs
index 24add38..a566035 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -7,10 +7,10 @@ pub mod gpg;
pub mod interactive;
pub mod models;
pub mod operations;
-pub mod parser;
pub mod secure_temp;
pub mod security;
pub mod service;
+pub mod toml_parser;
pub mod utils;
// Re-export commonly used types
diff --git a/src/main.rs b/src/main.rs
index 6a19d88..d28a29e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -58,7 +58,7 @@ async fn run_interactive() {
// No vault file found, prompt user to create one
eprintln!("{}", "No vault file found.".yellow());
- let vault_path = PathBuf::from("vault.md");
+ let vault_path = PathBuf::from("vault.toml");
let full_path = std::env::current_dir()
.unwrap_or_default()
.join(&vault_path);
@@ -89,8 +89,9 @@ async fn run_interactive() {
};
if create {
- // Create new vault file
- let content = vaultify::parser::VaultParser::create_root_document();
+ // Create new vault file with TOML format
+ let now = chrono::Utc::now().to_rfc3339();
+ let content = format!("version = \"v0.3\"\ncreated = \"{now}\"\n");
match std::fs::write(&vault_path, content) {
Ok(_) => {
// Set secure permissions
diff --git a/src/models.rs b/src/models.rs
index e2d2d0d..db95c72 100644
--- a/src/models.rs
+++ b/src/models.rs
@@ -1,6 +1,7 @@
//! Data models for the credential vault.
use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
use std::path::PathBuf;
/// Represents a single entry in the vault.
@@ -8,24 +9,21 @@ use std::path::PathBuf;
pub struct VaultEntry {
/// Scope path, e.g., ("personal", "banking", "chase")
pub scope_path: Vec,
- /// Markdown heading depth (1-6)
- pub heading_level: u8,
/// Plain text description
pub description: String,
/// Base64 encrypted secret
pub encrypted_content: String,
- /// Starting line in file (0-based)
- pub start_line: usize,
- /// Ending line in file (0-based)
- pub end_line: usize,
/// Per-item salt for key derivation
pub salt: Option>,
+ /// Custom fields for extensibility (TOML format)
+ #[serde(default, skip_serializing_if = "HashMap::is_empty")]
+ pub custom_fields: HashMap,
}
impl VaultEntry {
- /// Get scope as slash-separated string.
+ /// Get scope as dot-separated string.
pub fn scope_string(&self) -> String {
- self.scope_path.join("/")
+ self.scope_path.join(".")
}
/// Check if this entry has encrypted content.
@@ -52,7 +50,7 @@ impl VaultEntry {
pub struct VaultDocument {
/// All vault entries in order
pub entries: Vec,
- /// Original file lines with newlines
+ /// Raw lines (not used in TOML format, kept for compatibility)
pub raw_lines: Vec,
/// Path to the vault file
pub file_path: Option,
@@ -112,59 +110,42 @@ impl VaultDocument {
/// Add a new entry to the document.
pub fn add_entry(&mut self, entry: VaultEntry) -> Result<(), Box> {
- use crate::parser::VaultParser;
+ // Find the correct position to insert within the group
+ // We want to maintain group cohesion - all entries with the same top-level
+ // prefix should be together, with new entries added at the end of their group
- // Create parser for formatting
- let parser = VaultParser::new();
-
- // Format the new entry
- let entry_lines = parser.format_entry(&entry);
-
- // Find insertion point
- let parent_path = if entry.scope_path.len() > 1 {
- &entry.scope_path[..entry.scope_path.len() - 1]
- } else {
- &[]
- };
-
- let insert_point = VaultParser::find_insertion_point(self, parent_path);
+ if self.entries.is_empty() {
+ self.entries.push(entry);
+ return Ok(());
+ }
- // Create any missing ancestors
- let ancestors = VaultParser::create_missing_ancestors(self, &entry.scope_path);
+ // Get the top-level prefix of the new entry
+ let new_prefix = &entry.scope_path[0];
- // Insert ancestors first
- let mut current_insert = insert_point;
- for ancestor in ancestors {
- let ancestor_lines = parser.format_entry(&ancestor);
- for (i, line) in ancestor_lines.iter().enumerate() {
- self.raw_lines.insert(current_insert + i, line.clone());
+ // Find the last index of entries with the same top-level prefix
+ let mut insert_position = None;
+ for (i, existing) in self.entries.iter().enumerate() {
+ if !existing.scope_path.is_empty() && &existing.scope_path[0] == new_prefix {
+ // Found an entry with the same prefix, update insert position
+ insert_position = Some(i + 1);
}
- current_insert += ancestor_lines.len();
- self.entries.push(ancestor);
}
- // Insert the new entry
- for (i, line) in entry_lines.iter().enumerate() {
- self.raw_lines.insert(current_insert + i, line.clone());
+ // If we found entries with the same prefix, insert after the last one
+ // Otherwise, append to the end
+ match insert_position {
+ Some(pos) => self.entries.insert(pos, entry),
+ None => self.entries.push(entry),
}
- // No need to add blank lines here since they're included in format_entry
-
- self.entries.push(entry);
-
- // Re-parse to update line numbers
- let content = self.raw_lines.join("");
- let new_doc = parser.parse(&content)?;
- self.entries = new_doc.entries;
-
Ok(())
}
/// Save the document to a file.
- pub fn save(&self, path: &std::path::Path) -> Result<(), std::io::Error> {
- use std::fs;
- let content = self.raw_lines.join("");
- fs::write(path, content)
+ pub fn save(&self, _path: &std::path::Path) -> Result<(), std::io::Error> {
+ // This method is no longer used - saving is handled by VaultService
+ // which uses TomlParser::format() to generate the content
+ Ok(())
}
}
@@ -176,36 +157,30 @@ mod tests {
fn test_vault_entry_scope_string() {
let entry = VaultEntry {
scope_path: vec!["personal".to_string(), "banking".to_string()],
- heading_level: 2,
description: "Banking info".to_string(),
encrypted_content: "".to_string(),
- start_line: 0,
- end_line: 5,
salt: None,
+ custom_fields: HashMap::new(),
};
- assert_eq!(entry.scope_string(), "personal/banking");
+ assert_eq!(entry.scope_string(), "personal.banking");
}
#[test]
fn test_vault_entry_relationships() {
let parent = VaultEntry {
scope_path: vec!["personal".to_string()],
- heading_level: 1,
description: "Personal".to_string(),
encrypted_content: "".to_string(),
- start_line: 0,
- end_line: 5,
salt: None,
+ custom_fields: HashMap::new(),
};
let child = VaultEntry {
scope_path: vec!["personal".to_string(), "banking".to_string()],
- heading_level: 2,
description: "Banking".to_string(),
encrypted_content: "".to_string(),
- start_line: 6,
- end_line: 10,
salt: None,
+ custom_fields: HashMap::new(),
};
assert!(parent.is_parent_of(&child));
diff --git a/src/operations.rs b/src/operations.rs
index be01953..186b376 100644
--- a/src/operations.rs
+++ b/src/operations.rs
@@ -78,8 +78,7 @@ impl VaultOperations {
});
}
- // Sort for consistent output
- entries.sort_by(|a, b| a.scope.cmp(&b.scope));
+ // Preserve the order from the vault file - no sorting
Ok(ListResult { entries })
}
diff --git a/src/parser.rs b/src/parser.rs
deleted file mode 100644
index bdf92e3..0000000
--- a/src/parser.rs
+++ /dev/null
@@ -1,425 +0,0 @@
-//! Order-preserving parser for vault markdown files.
-
-use crate::crypto::VaultCrypto;
-use crate::models::{VaultDocument, VaultEntry};
-use regex::Regex;
-use std::path::Path;
-use thiserror::Error;
-
-/// Errors that can occur during parsing.
-#[derive(Error, Debug)]
-pub enum ParseError {
- #[error("Unsupported vault version: {0}")]
- UnsupportedVersion(String),
- #[error("Invalid vault format")]
- InvalidFormat,
- #[error("IO error: {0}")]
- Io(#[from] std::io::Error),
-}
-
-/// Parse and manipulate vault markdown files while preserving order.
-pub struct VaultParser {
- // Supported versions
- supported_versions: Vec<&'static str>,
- #[allow(dead_code)]
- current_version: &'static str,
- // Regex patterns
- heading_pattern: Regex,
- version_pattern: Regex,
- description_start: Regex,
- description_end: Regex,
- encrypted_start: Regex,
- encrypted_end: Regex,
-}
-
-impl Default for VaultParser {
- fn default() -> Self {
- Self {
- supported_versions: vec!["v1"],
- current_version: "v1",
- heading_pattern: Regex::new(
- r"^(#{1,6})\s+(.+?)(?:\s*)?$",
- )
- .unwrap(),
- version_pattern: Regex::new(r"").unwrap(),
- description_start: Regex::new(r"^$").unwrap(),
- description_end: Regex::new(r"^$").unwrap(),
- encrypted_start: Regex::new(r#"^$"#).unwrap(),
- encrypted_end: Regex::new(r"^$").unwrap(),
- }
- }
-}
-
-impl VaultParser {
- /// Create a new parser instance.
- pub fn new() -> Self {
- Self::default()
- }
-
- /// Create a new root document with version.
- pub fn create_root_document() -> String {
- "# root \n".to_string()
- }
-
- /// Parse vault content into a VaultDocument.
- pub fn parse(&self, content: &str) -> Result {
- let lines: Vec = content.lines().map(|s| format!("{s}\n")).collect();
- let mut entries = Vec::new();
-
- // Extract and validate version
- if let Some(version) = self.extract_version(&lines) {
- if !self.supported_versions.contains(&version.as_str()) {
- return Err(ParseError::UnsupportedVersion(version));
- }
- }
-
- let mut i = 0;
- while i < lines.len() {
- let line = lines[i].trim_end();
-
- // Check if this is a heading
- if let Some(heading_match) = self.heading_pattern.captures(line) {
- let heading_text = heading_match.get(2).unwrap().as_str().trim();
- let heading_level = heading_match.get(1).unwrap().as_str().len();
-
- // Skip the root header (# root)
- if heading_level == 1 && heading_text.to_lowercase().starts_with("root") {
- i += 1;
- continue;
- }
-
- if let Some((entry, next_i)) = self.parse_entry(&lines, i, heading_match)? {
- entries.push(entry);
- i = next_i;
- } else {
- i += 1;
- }
- } else {
- i += 1;
- }
- }
-
- Ok(VaultDocument {
- entries,
- raw_lines: lines,
- file_path: None,
- })
- }
-
- /// Parse a vault file.
- pub fn parse_file(&self, path: &Path) -> Result {
- let content = std::fs::read_to_string(path)?;
- let mut doc = self.parse(&content)?;
- doc.file_path = Some(path.to_path_buf());
- Ok(doc)
- }
-
- /// Extract version from the first few lines.
- fn extract_version(&self, lines: &[String]) -> Option {
- for line in lines.iter().take(5) {
- if let Some(version_match) = self.version_pattern.captures(line) {
- return Some(version_match.get(1).unwrap().as_str().to_string());
- }
- }
- None
- }
-
- /// Parse a single vault entry starting from a heading.
- fn parse_entry(
- &self,
- lines: &[String],
- start_idx: usize,
- heading_match: regex::Captures,
- ) -> Result