use crate::failure::Failure;
use serde::{Deserialize, Serialize};
use std::fs::File;
use std::io::Write;
use std::path::Path;
pub const FILE: &str = crate::store::LANE;
pub const MAIN: &str = "main";
const VERSION: u32 = 1;
#[derive(Debug, Serialize, Deserialize)]
pub struct Lane {
pub version: u32,
pub id: String,
pub project: String,
}
pub fn new_id() -> String {
crate::id::ulid()
}
pub fn name_for(id: &str, _folder_name: &str) -> String {
format!("lane-{}", &id[..id.len().min(4)])
}
pub fn declared_name(id: &str, folder_name: &str) -> String {
match crate::redact::check_field("lane name", folder_name) {
Some(_) => name_for(id, folder_name),
None => folder_name.to_string(),
}
}
pub fn read(vivac_dir: &Path) -> Result<Option<Lane>, Failure> {
let raw = match std::fs::read_to_string(vivac_dir.join(FILE)) {
Ok(raw) => raw,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e.into()),
};
let v: serde_json::Value =
serde_json::from_str(&raw).map_err(|e| Failure::Io(std::io::Error::other(e)))?;
check_lane_version(v.get("version"))?;
let lane: Lane =
serde_json::from_value(v).map_err(|e| Failure::Io(std::io::Error::other(e)))?;
Ok(Some(lane))
}
fn check_lane_version(version: Option<&serde_json::Value>) -> Result<(), Failure> {
if let Some(other) = version.and_then(|v| v.as_u64()) {
if other != VERSION as u64 {
return Err(Failure::newer_vivac(format!(
"This tree was written by a newer vivac: .vivac/lane has version {other}, \
which this version does not know. Update vivac to read it. A session or \
vivac web opened before an update keeps the old vivac until it restarts. \
Nothing was written."
)));
}
}
Ok(())
}
pub fn write(vivac_dir: &Path, lane: &Lane) -> std::io::Result<()> {
let built = crate::store::build_fresh(vivac_dir, |tmp_dir| {
write_lane_file(tmp_dir, lane)?;
crate::store::write_gitignore(tmp_dir)
})?;
if built {
crate::store::mark_write();
return Ok(());
}
write_in_place(vivac_dir, lane)
}
fn write_in_place(vivac_dir: &Path, lane: &Lane) -> std::io::Result<()> {
std::fs::create_dir_all(vivac_dir)?;
write_lane_file(vivac_dir, lane)?;
crate::store::mark_write();
crate::store::write_gitignore(vivac_dir)
}
fn write_lane_file(vivac_dir: &Path, lane: &Lane) -> std::io::Result<()> {
let tmp = vivac_dir.join(format!("{FILE}.{}.tmp", crate::id::ulid()));
{
let mut f = File::create(&tmp)?;
f.write_all(serde_json::to_string_pretty(lane)?.as_bytes())?;
f.write_all(b"\n")?;
}
std::fs::rename(&tmp, vivac_dir.join(FILE))
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_vivac(prefix: &str) -> std::path::PathBuf {
let root = std::env::temp_dir().join(format!("vivac-{prefix}-{}", crate::id::ulid()));
let dir = root.join(crate::store::DIR);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn a_lane_file_round_trips() {
let dir = temp_vivac("rt");
let l = Lane {
version: 1,
id: "01M2AAAAAAAAAAAAAAAAAAAAAA".into(),
project: "01M1BBBBBBBBBBBBBBBBBBBBBB".into(),
};
write(&dir, &l).unwrap();
let back = read(&dir).unwrap().unwrap();
assert_eq!(back.id, l.id);
assert_eq!(back.project, l.project);
std::fs::remove_dir_all(dir.parent().unwrap()).ok();
}
#[test]
fn no_lane_file_is_not_a_failure() {
let dir = temp_vivac("absent");
assert!(read(&dir).unwrap().is_none());
std::fs::remove_dir_all(dir.parent().unwrap()).ok();
}
#[test]
fn a_version_this_release_does_not_know_refuses() {
let dir = temp_vivac("newer");
std::fs::write(
dir.join(FILE),
br#"{"version":2,"id":"01M2","project":"01M1"}"#,
)
.unwrap();
let e = read(&dir).unwrap_err();
assert_eq!(e.code(), 5);
assert!(e.message().contains("newer vivac"), "{}", e.message());
std::fs::remove_dir_all(dir.parent().unwrap()).ok();
}
#[test]
fn the_lane_file_is_replaced_whole_and_never_half_written() {
let dir = temp_vivac("atomic");
let first = Lane {
version: 1,
id: "01A".into(),
project: "01P".into(),
};
write(&dir, &first).unwrap();
let second = Lane {
version: 1,
id: "01B".into(),
project: "01P".into(),
};
write(&dir, &second).unwrap();
assert_eq!(read(&dir).unwrap().unwrap().id, "01B");
assert!(
std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.all(|e| !e.file_name().to_string_lossy().ends_with(".tmp")),
"a temporary file was left behind"
);
std::fs::remove_dir_all(dir.parent().unwrap()).ok();
}
#[test]
fn writing_a_lane_keeps_the_folder_out_of_git() {
let dir = temp_vivac("ignored");
write(
&dir,
&Lane {
version: 1,
id: "01A".into(),
project: "01P".into(),
},
)
.unwrap();
assert_eq!(
std::fs::read_to_string(dir.join(crate::store::GITIGNORE)).unwrap(),
"*\n"
);
std::fs::remove_dir_all(dir.parent().unwrap()).ok();
}
#[test]
fn the_lane_file_holds_three_fields_and_no_room_for_a_path() {
let dir = temp_vivac("shape");
write(
&dir,
&Lane {
version: 1,
id: "01A".into(),
project: "01P".into(),
},
)
.unwrap();
let text = std::fs::read_to_string(dir.join(crate::store::LANE)).unwrap();
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
let obj = v.as_object().unwrap();
let mut keys: Vec<&str> = obj.keys().map(|k| k.as_str()).collect();
keys.sort();
assert_eq!(keys, ["id", "project", "version"]);
std::fs::remove_dir_all(dir.parent().unwrap()).ok();
}
#[test]
fn a_lane_file_that_is_not_json_refuses() {
let dir = temp_vivac("notjson");
std::fs::write(dir.join(FILE), b"not json at all").unwrap();
let e = read(&dir).unwrap_err();
assert_ne!(
e.code(),
4,
"a corrupt lane file was read as though there were no tree here: {}",
e.message()
);
std::fs::remove_dir_all(dir.parent().unwrap()).ok();
}
#[test]
fn a_lane_file_with_the_wrong_shape_refuses() {
let dir = temp_vivac("wrongshape");
std::fs::write(dir.join(FILE), br#"{"version":1}"#).unwrap();
let e = read(&dir).unwrap_err();
assert_ne!(
e.code(),
4,
"a corrupt lane file was read as though there were no tree here: {}",
e.message()
);
std::fs::remove_dir_all(dir.parent().unwrap()).ok();
}
#[test]
fn a_reader_racing_a_fresh_write_never_sees_vivac_dir_hollow() {
const ROUNDS: usize = 500;
for round in 0..ROUNDS {
let root = std::env::temp_dir().join(format!("vivac-race-{}", crate::id::ulid()));
let dir = root.join(crate::store::DIR);
let hollow_seen = std::sync::atomic::AtomicBool::new(false);
let stop = std::sync::atomic::AtomicBool::new(false);
std::thread::scope(|s| {
s.spawn(|| {
while !stop.load(std::sync::atomic::Ordering::Relaxed) {
if dir.is_dir() && !dir.join(FILE).is_file() {
hollow_seen.store(true, std::sync::atomic::Ordering::Relaxed);
break;
}
}
});
let lane = Lane {
version: 1,
id: "01A".into(),
project: "01P".into(),
};
write(&dir, &lane).unwrap();
stop.store(true, std::sync::atomic::Ordering::Relaxed);
});
std::fs::remove_dir_all(&root).ok();
assert!(
!hollow_seen.load(std::sync::atomic::Ordering::Relaxed),
"a reader observed a hollow .vivac/ on round {round} of {ROUNDS}"
);
}
}
#[test]
fn a_fresh_vivac_dir_holds_lane_and_gitignore_with_no_temp_sibling_left_behind() {
let root = std::env::temp_dir().join(format!("vivac-fresh-{}", crate::id::ulid()));
let dir = root.join(crate::store::DIR);
assert!(
!dir.exists(),
"the fixture must start with no .vivac/ at all"
);
let l = Lane {
version: 1,
id: "01A".into(),
project: "01P".into(),
};
write(&dir, &l).unwrap();
assert_eq!(read(&dir).unwrap().unwrap().id, "01A");
assert_eq!(
std::fs::read_to_string(dir.join(crate::store::GITIGNORE)).unwrap(),
"*\n"
);
assert_eq!(
only_entry_of(&root),
crate::store::DIR,
"a temporary sibling directory was left behind"
);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn writing_into_an_existing_vivac_dir_keeps_writing_in_place() {
let dir = temp_vivac("existing");
let l = Lane {
version: 1,
id: "01A".into(),
project: "01P".into(),
};
write(&dir, &l).unwrap();
assert_eq!(read(&dir).unwrap().unwrap().id, "01A");
let root = dir.parent().unwrap();
assert_eq!(only_entry_of(root), crate::store::DIR);
std::fs::remove_dir_all(root).ok();
}
fn only_entry_of(dir: &Path) -> String {
let entries: Vec<String> = std::fs::read_dir(dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(
entries.len(),
1,
"expected exactly one entry, found {entries:?}"
);
entries.into_iter().next().unwrap()
}
}