use crate::failure::Failure;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
const FILE: &str = "projects";
pub fn marks_global_store(dir: &Path) -> bool {
dir.join(FILE).is_file()
}
const VERSION: u32 = 1;
#[derive(Debug, Serialize, Deserialize)]
struct Contents {
version: u32,
projects: BTreeMap<String, String>,
}
impl Contents {
fn empty() -> Contents {
Contents {
version: VERSION,
projects: BTreeMap::new(),
}
}
}
pub fn note(store_dir: &Path, project_id: &str, root: &Path) {
let _ = try_note(store_dir, project_id, root);
}
fn try_note(store_dir: &Path, project_id: &str, root: &Path) -> std::io::Result<()> {
let path = store_dir.join(FILE);
let mut contents = read(&path);
let value = root.to_string_lossy().into_owned();
if contents.projects.get(project_id) == Some(&value) {
return Ok(());
}
contents.projects.insert(project_id.to_string(), value);
write(store_dir, &path, &contents)
}
pub fn roots(store_dir: &Path) -> Vec<PathBuf> {
read(&store_dir.join(FILE))
.projects
.into_values()
.map(PathBuf::from)
.filter(|r| !marks_global_store(&r.join(crate::store::DIR)))
.collect()
}
pub fn resolve(spec: &str) -> Result<PathBuf, Failure> {
let known = crate::store::store_dir()
.map(|d| roots(&d))
.unwrap_or_default();
let mut matches: Vec<PathBuf> = known
.into_iter()
.filter(|root| crate::render::project_name(root) == spec)
.collect();
match matches.len() {
1 => Ok(matches.remove(0)),
0 => Ok(PathBuf::from(spec)),
n => Err(Failure::usage(format!(
"\"{spec}\" names {n} projects on this machine. Pass a path instead."
))),
}
}
fn read(path: &Path) -> Contents {
std::fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_else(Contents::empty)
}
fn write(store_dir: &Path, path: &Path, contents: &Contents) -> std::io::Result<()> {
std::fs::create_dir_all(store_dir)?;
let mut f = File::create(path)?;
f.write_all(serde_json::to_string_pretty(contents)?.as_bytes())?;
f.write_all(b"\n")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{id, store};
fn temp_dir(prefix: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("vivac-{prefix}-{}", id::ulid()))
}
fn seeded_project(prefix: &str) -> (std::path::PathBuf, String) {
let root = temp_dir(prefix);
let mut s = store::Store::create(&root).unwrap();
s.append(
vec![crate::event::Body::NodeNoted {
node: "t1".into(),
note: "seed".into(),
}],
0,
false,
)
.unwrap();
let id = store::first_event_id(&root).unwrap();
(root, id)
}
#[test]
fn a_fresh_registry_gets_the_project_inserted() {
let store_dir = temp_dir("reg");
let (root, id) = seeded_project("proj");
note(&store_dir, &id, &root);
let text = std::fs::read_to_string(store_dir.join(FILE)).unwrap();
let contents: Contents = serde_json::from_str(&text).unwrap();
assert_eq!(
contents.projects.get(&id),
Some(&root.to_string_lossy().into_owned())
);
std::fs::remove_dir_all(&store_dir).ok();
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn registering_the_same_project_twice_writes_nothing() {
let store_dir = temp_dir("reg");
let (root, id) = seeded_project("proj");
note(&store_dir, &id, &root);
let before = std::fs::read(store_dir.join(FILE)).unwrap();
note(&store_dir, &id, &root);
let after = std::fs::read(store_dir.join(FILE)).unwrap();
assert_eq!(
before, after,
"the no-write case is the one that protects the budget"
);
std::fs::remove_dir_all(&store_dir).ok();
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn a_moved_project_updates_in_place() {
let store_dir = temp_dir("reg");
let (old_root, id) = seeded_project("proj");
let new_root = temp_dir("proj-moved");
note(&store_dir, &id, &old_root);
note(&store_dir, &id, &new_root);
let text = std::fs::read_to_string(store_dir.join(FILE)).unwrap();
let contents: Contents = serde_json::from_str(&text).unwrap();
assert_eq!(contents.projects.len(), 1);
assert_eq!(
contents.projects.get(&id),
Some(&new_root.to_string_lossy().into_owned())
);
std::fs::remove_dir_all(&store_dir).ok();
std::fs::remove_dir_all(&old_root).ok();
}
#[test]
fn two_different_projects_both_appear() {
let store_dir = temp_dir("reg");
let (root_a, id_a) = seeded_project("proj-a");
let (root_b, id_b) = seeded_project("proj-b");
note(&store_dir, &id_a, &root_a);
note(&store_dir, &id_b, &root_b);
let text = std::fs::read_to_string(store_dir.join(FILE)).unwrap();
let contents: Contents = serde_json::from_str(&text).unwrap();
assert_eq!(contents.projects.len(), 2);
assert_eq!(
contents.projects.get(&id_a),
Some(&root_a.to_string_lossy().into_owned())
);
assert_eq!(
contents.projects.get(&id_b),
Some(&root_b.to_string_lossy().into_owned())
);
std::fs::remove_dir_all(&store_dir).ok();
std::fs::remove_dir_all(&root_a).ok();
std::fs::remove_dir_all(&root_b).ok();
}
#[test]
fn an_unwritable_store_directory_never_fails_the_caller() {
let blocked = temp_dir("blocked");
std::fs::write(&blocked, b"not a directory").unwrap();
let (root, id) = seeded_project("proj");
note(&blocked, &id, &root);
std::fs::remove_file(&blocked).ok();
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn a_nonexistent_store_directory_never_fails_the_caller() {
let store_dir = temp_dir("does-not-exist-yet");
let (root, id) = seeded_project("proj");
note(&store_dir, &id, &root);
assert!(store_dir.join(FILE).exists());
std::fs::remove_dir_all(&store_dir).ok();
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn garbage_in_projects_never_fails_the_caller_and_gets_replaced() {
let store_dir = temp_dir("reg");
std::fs::create_dir_all(&store_dir).unwrap();
std::fs::write(store_dir.join(FILE), b"not json at all").unwrap();
let (root, id) = seeded_project("proj");
note(&store_dir, &id, &root);
let text = std::fs::read_to_string(store_dir.join(FILE)).unwrap();
let contents: Contents = serde_json::from_str(&text).unwrap();
assert_eq!(
contents.projects.get(&id),
Some(&root.to_string_lossy().into_owned())
);
std::fs::remove_dir_all(&store_dir).ok();
std::fs::remove_dir_all(&root).ok();
}
}