forked from
stavola.xyz/mlf
A human-friendly DSL for ATProto Lexicons
1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4use thiserror::Error;
5
6#[derive(Error, Debug)]
7pub enum ConfigError {
8 #[error("Failed to read config file: {0}")]
9 ReadError(#[from] std::io::Error),
10
11 #[error("Failed to parse config file: {0}")]
12 ParseError(#[from] toml::de::Error),
13
14 #[error("No mlf.toml found in current directory or parent directories")]
15 NotFound,
16}
17
18#[derive(Debug, Serialize, Deserialize)]
19pub struct MlfConfig {
20 #[serde(default)]
21 pub source: SourceConfig,
22
23 #[serde(default, skip_serializing_if = "Vec::is_empty")]
24 pub output: Vec<OutputConfig>,
25
26 #[serde(default)]
27 pub dependencies: DependenciesConfig,
28}
29
30#[derive(Debug, Serialize, Deserialize)]
31pub struct SourceConfig {
32 #[serde(default = "default_source_directory", skip_serializing_if = "is_default_source_directory")]
33 pub directory: String,
34}
35
36impl Default for SourceConfig {
37 fn default() -> Self {
38 Self {
39 directory: default_source_directory(),
40 }
41 }
42}
43
44fn default_source_directory() -> String {
45 "./lexicons".to_string()
46}
47
48fn is_default_source_directory(s: &str) -> bool {
49 s == default_source_directory()
50}
51
52#[derive(Debug, Serialize, Deserialize)]
53pub struct OutputConfig {
54 pub r#type: String,
55 pub directory: String,
56}
57
58#[derive(Debug, Serialize, Deserialize)]
59pub struct DependenciesConfig {
60 #[serde(default)]
61 pub dependencies: Vec<String>,
62
63 #[serde(default = "default_allow_transitive_deps", skip_serializing_if = "is_default_allow_transitive_deps")]
64 pub allow_transitive_deps: bool,
65
66 #[serde(default = "default_optimize_transitive_fetches", skip_serializing_if = "is_default_optimize_transitive_fetches")]
67 pub optimize_transitive_fetches: bool,
68}
69
70fn default_allow_transitive_deps() -> bool {
71 true
72}
73
74fn default_optimize_transitive_fetches() -> bool {
75 false
76}
77
78fn is_default_allow_transitive_deps(b: &bool) -> bool {
79 *b == default_allow_transitive_deps()
80}
81
82fn is_default_optimize_transitive_fetches(b: &bool) -> bool {
83 *b == default_optimize_transitive_fetches()
84}
85
86impl Default for DependenciesConfig {
87 fn default() -> Self {
88 Self {
89 dependencies: vec![],
90 allow_transitive_deps: default_allow_transitive_deps(),
91 optimize_transitive_fetches: default_optimize_transitive_fetches(),
92 }
93 }
94}
95
96impl Default for MlfConfig {
97 fn default() -> Self {
98 Self {
99 source: SourceConfig::default(),
100 output: vec![],
101 dependencies: DependenciesConfig::default(),
102 }
103 }
104}
105
106impl MlfConfig {
107 pub fn load(path: &Path) -> Result<Self, ConfigError> {
108 let content = std::fs::read_to_string(path)?;
109 let config: MlfConfig = toml::from_str(&content)?;
110 Ok(config)
111 }
112
113 pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
114 let content = toml::to_string_pretty(self)
115 .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
116 std::fs::write(path, content)?;
117 Ok(())
118 }
119
120 pub fn create_default(path: &Path) -> Result<Self, ConfigError> {
121 let config = MlfConfig::default();
122 config.save(path)?;
123 Ok(config)
124 }
125}
126
127/// Find the project root by looking for mlf.toml
128/// Searches upward from the given directory
129pub fn find_project_root(start_dir: &Path) -> Result<PathBuf, ConfigError> {
130 let mut current = start_dir.to_path_buf();
131 const MAX_DEPTH: usize = 10;
132
133 for _ in 0..MAX_DEPTH {
134 let config_path = current.join("mlf.toml");
135 if config_path.exists() {
136 return Ok(current);
137 }
138
139 match current.parent() {
140 Some(parent) => current = parent.to_path_buf(),
141 None => break,
142 }
143 }
144
145 Err(ConfigError::NotFound)
146}
147
148/// Get or create the .mlf cache directory
149pub fn get_mlf_cache_dir(project_root: &Path) -> PathBuf {
150 project_root.join(".mlf")
151}
152
153/// Initialize the .mlf directory structure
154pub fn init_mlf_cache(project_root: &Path) -> std::io::Result<()> {
155 let mlf_dir = get_mlf_cache_dir(project_root);
156
157 // Create directory structure
158 std::fs::create_dir_all(&mlf_dir)?;
159 std::fs::create_dir_all(mlf_dir.join("lexicons/json"))?;
160 std::fs::create_dir_all(mlf_dir.join("lexicons/mlf"))?;
161
162 // Create .gitignore
163 let gitignore_path = mlf_dir.join(".gitignore");
164 if !gitignore_path.exists() {
165 std::fs::write(&gitignore_path, "*\n!.gitignore\n")?;
166 }
167
168 Ok(())
169}
170
171/// Lock file format for tracking resolved lexicons
172#[derive(Debug, Serialize, Deserialize, Default)]
173pub struct LockFile {
174 /// Lock file format version
175 pub version: u32,
176
177 /// All resolved lexicons (both direct and transitive dependencies)
178 #[serde(default)]
179 pub lexicons: HashMap<String, LockedLexicon>,
180}
181
182/// A single locked lexicon entry
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct LockedLexicon {
185 /// The NSID of this lexicon
186 pub nsid: String,
187
188 /// The DID of the repository this was fetched from
189 pub did: String,
190
191 /// SHA-256 checksum of the JSON content
192 pub checksum: String,
193
194 /// List of NSIDs this lexicon depends on (external references)
195 #[serde(default, skip_serializing_if = "Vec::is_empty")]
196 pub dependencies: Vec<String>,
197}
198
199impl LockFile {
200 pub fn new() -> Self {
201 Self {
202 version: 1,
203 lexicons: HashMap::new(),
204 }
205 }
206
207 pub fn load(path: &Path) -> Result<Self, ConfigError> {
208 if !path.exists() {
209 return Ok(Self::new());
210 }
211
212 let content = std::fs::read_to_string(path)?;
213 toml::from_str(&content).map_err(|e| ConfigError::ParseError(e))
214 }
215
216 pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
217 let content = toml::to_string_pretty(self)
218 .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
219 std::fs::write(path, content)?;
220 Ok(())
221 }
222
223 pub fn add_lexicon(&mut self, nsid: String, did: String, checksum: String, dependencies: Vec<String>) {
224 self.lexicons.insert(nsid.clone(), LockedLexicon {
225 nsid,
226 did,
227 checksum,
228 dependencies,
229 });
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 #[test]
238 fn test_default_config() {
239 let config = MlfConfig::default();
240 assert_eq!(config.source.directory, "./lexicons");
241 assert!(config.output.is_empty());
242 assert!(config.dependencies.dependencies.is_empty());
243 }
244
245 #[test]
246 fn test_lockfile_basic() {
247 let mut lockfile = LockFile::new();
248 assert_eq!(lockfile.version, 1);
249 assert!(lockfile.lexicons.is_empty());
250
251 lockfile.add_lexicon(
252 "app.bsky.actor.profile".to_string(),
253 "did:plc:test".to_string(),
254 "sha256:abc123".to_string(),
255 vec![],
256 );
257
258 assert_eq!(lockfile.lexicons.len(), 1);
259 let locked = lockfile.lexicons.get("app.bsky.actor.profile").unwrap();
260 assert_eq!(locked.nsid, "app.bsky.actor.profile");
261 assert_eq!(locked.did, "did:plc:test");
262 assert_eq!(locked.checksum, "sha256:abc123");
263 }
264}