use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::Path;
use crate::model::{Graph, Node};
const LOCK_FILE: &str = "drft.lock";
const HEADER: &str = "# drft.lock — generated by `drft lock`. Do not edit by hand.\n\n";
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Lock {
pub nodes: BTreeMap<String, LockedNode>,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct LockedNode {
pub hash: Option<String>,
pub edges: BTreeMap<String, Option<String>>,
}
impl Lock {
pub fn from_composed(graph: &Graph) -> Self {
let mut nodes: BTreeMap<String, LockedNode> = BTreeMap::new();
for (path, node) in &graph.nodes {
nodes.insert(
path.clone(),
LockedNode {
hash: node.fs_hash().map(str::to_string),
edges: BTreeMap::new(),
},
);
}
for edge in &graph.edges {
let entry = nodes.entry(edge.source.clone()).or_default();
entry.edges.insert(
edge.target.clone(),
graph
.nodes
.get(&edge.target)
.and_then(Node::fs_hash)
.map(str::to_string),
);
}
nodes.retain(|_, node| node.hash.is_some() || !node.edges.is_empty());
Lock { nodes }
}
pub fn to_toml(&self) -> Result<String> {
let doc = LockToml {
node: self
.nodes
.iter()
.map(|(path, node)| NodeToml {
path: path.clone(),
hash: node.hash.clone(),
edge: node
.edges
.iter()
.map(|(target, target_hash)| EdgeToml {
target: target.clone(),
target_hash: target_hash.clone(),
})
.collect(),
})
.collect(),
};
let body = toml::to_string_pretty(&doc).context("failed to serialize lockfile")?;
Ok(format!("{HEADER}{body}"))
}
pub fn from_toml(content: &str) -> Result<Self> {
let doc: LockToml = toml::from_str(content).context("failed to parse lockfile")?;
let mut nodes = BTreeMap::new();
for node in doc.node {
let edges = node
.edge
.into_iter()
.map(|e| (e.target, e.target_hash))
.collect();
nodes.insert(
node.path,
LockedNode {
hash: node.hash,
edges,
},
);
}
Ok(Lock { nodes })
}
}
#[derive(Debug, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
struct LockToml {
#[serde(rename = "node", default)]
node: Vec<NodeToml>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct NodeToml {
path: String,
#[serde(skip_serializing_if = "Option::is_none")]
hash: Option<String>,
#[serde(rename = "edge", default, skip_serializing_if = "Vec::is_empty")]
edge: Vec<EdgeToml>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct EdgeToml {
target: String,
#[serde(skip_serializing_if = "Option::is_none")]
target_hash: Option<String>,
}
pub fn read(root: &Path) -> Result<Option<Lock>> {
let path = root.join(LOCK_FILE);
if !path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
match Lock::from_toml(&content) {
Ok(lock) => Ok(Some(lock)),
Err(e) => {
eprintln!("warn: could not parse {LOCK_FILE} ({e}) — run `drft lock` to regenerate");
Ok(None)
}
}
}
pub fn write(root: &Path, lock: &Lock) -> Result<()> {
let content = lock.to_toml()?;
let lock_path = root.join(LOCK_FILE);
let tmp_path = root.join("drft.lock.tmp");
std::fs::write(&tmp_path, &content)
.with_context(|| format!("failed to write {}", tmp_path.display()))?;
std::fs::rename(&tmp_path, &lock_path).with_context(|| {
let _ = std::fs::remove_file(&tmp_path);
format!(
"failed to rename {} to {}",
tmp_path.display(),
lock_path.display()
)
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::compose::compose;
use crate::model::{Edge, GraphSet, Node};
use serde_json::json;
use tempfile::TempDir;
fn fs_fragment(entries: &[(&str, &str)]) -> Graph {
let mut g = Graph::labeled("fs");
for (path, hash) in entries {
g.set_node(
*path,
Node::new(
json!({ "type": "file", "hash": hash })
.as_object()
.unwrap()
.clone(),
),
);
}
g
}
#[test]
fn from_composed_captures_hashes_and_edges() {
let mut fs = fs_fragment(&[("index.md", "b3:idx"), ("setup.md", "b3:setup")]);
fs.add_edge(Edge::new("index.md", "setup.md"));
let composed = compose(&GraphSet::new(vec![fs]));
let lock = Lock::from_composed(&composed);
assert_eq!(lock.nodes["index.md"].hash.as_deref(), Some("b3:idx"));
assert_eq!(
lock.nodes["index.md"].edges["setup.md"].as_deref(),
Some("b3:setup")
);
assert!(lock.nodes["setup.md"].edges.is_empty());
}
#[test]
fn directory_nodes_are_omitted_but_resolve_edges() {
let mut fs = fs_fragment(&[("index.md", "b3:idx")]);
fs.set_node(
"guides",
Node::new(json!({ "type": "directory" }).as_object().unwrap().clone()),
);
fs.add_edge(Edge::new("index.md", "guides"));
let lock = Lock::from_composed(&compose(&GraphSet::new(vec![fs])));
assert!(
!lock.nodes.contains_key("guides"),
"hash-less, edge-less directory node must not be locked"
);
assert!(
lock.nodes["index.md"].edges.contains_key("guides"),
"the link to the directory must still be recorded"
);
assert_eq!(lock.nodes["index.md"].edges["guides"], None);
}
#[test]
fn toml_round_trips() {
let mut fs = fs_fragment(&[("a.md", "b3:a"), ("b.md", "b3:b")]);
fs.add_edge(Edge::new("a.md", "b.md"));
let lock = Lock::from_composed(&compose(&GraphSet::new(vec![fs])));
let toml = lock.to_toml().unwrap();
assert!(toml.contains("[[node]]"));
assert!(toml.contains("[[node.edge]]"));
assert!(!toml.contains("version"));
let parsed = Lock::from_toml(&toml).unwrap();
assert_eq!(lock, parsed);
}
#[test]
fn deterministic_output() {
let fs = fs_fragment(&[("z.md", "b3:z"), ("a.md", "b3:a")]);
let lock = Lock::from_composed(&compose(&GraphSet::new(vec![fs])));
assert_eq!(lock.to_toml().unwrap(), lock.to_toml().unwrap());
}
#[test]
fn write_then_read() {
let dir = TempDir::new().unwrap();
let fs = fs_fragment(&[("a.md", "b3:a")]);
let lock = Lock::from_composed(&compose(&GraphSet::new(vec![fs])));
write(dir.path(), &lock).unwrap();
assert_eq!(read(dir.path()).unwrap().unwrap(), lock);
}
#[test]
fn read_missing_is_none() {
let dir = TempDir::new().unwrap();
assert!(read(dir.path()).unwrap().is_none());
}
#[test]
fn unparseable_lock_warns_and_returns_none() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join(LOCK_FILE), "this is not valid toml {{{").unwrap();
assert!(read(dir.path()).unwrap().is_none());
}
}