use std::io::Write;
use std::path::Path;
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use crate::config::VcsDefault;
use crate::paths::ShokaPaths;
pub const SHELF_VERSION: u32 = 2;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Shelf {
#[serde(default = "default_version")]
pub version: u32,
#[serde(default)]
pub repos: Vec<Repo>,
}
fn default_version() -> u32 {
SHELF_VERSION
}
impl Default for Shelf {
fn default() -> Self {
Self {
version: SHELF_VERSION,
repos: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Repo {
pub host: String,
pub owner: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vcs: Option<VcsDefault>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<PathBuf>,
}
impl Repo {
pub fn new(host: impl Into<String>, owner: impl Into<String>, name: impl Into<String>) -> Self {
Self {
host: host.into(),
owner: owner.into(),
name: name.into(),
vcs: None,
tags: Vec::new(),
note: None,
path: None,
}
}
pub fn with_path(mut self, path: PathBuf) -> Self {
self.path = Some(path);
self
}
pub fn slug(&self) -> String {
format!("{}/{}/{}", self.host, self.owner, self.name)
}
}
impl Shelf {
pub fn load(paths: &ShokaPaths) -> Result<Self> {
Self::load_from(paths.state_file().as_path())
}
pub fn load_from(path: &Path) -> Result<Self> {
let raw = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
Err(e) => {
return Err(e).with_context(|| format!("reading shelf from {}", path.display()));
}
};
let shelf: Shelf =
toml::from_str(&raw).with_context(|| format!("parsing shelf at {}", path.display()))?;
if shelf.version > SHELF_VERSION {
bail!(
"shelf at {} has schema version {}, newer than this build's {} — upgrade shoka",
path.display(),
shelf.version,
SHELF_VERSION
);
}
Ok(shelf)
}
pub fn save(&self, paths: &ShokaPaths) -> Result<()> {
self.save_to(paths.state_file().as_path())
}
pub fn save_to(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating state dir {}", parent.display()))?;
}
let body = toml::to_string_pretty(self).context("serialising shelf to TOML")?;
let tmp = path.with_extension(format!("toml.{}.tmp", std::process::id()));
{
let mut f = std::fs::File::create(&tmp)
.with_context(|| format!("creating temp shelf file {}", tmp.display()))?;
f.write_all(body.as_bytes())
.with_context(|| format!("writing temp shelf file {}", tmp.display()))?;
f.sync_all()
.with_context(|| format!("syncing temp shelf file {}", tmp.display()))?;
}
std::fs::rename(&tmp, path)
.with_context(|| format!("renaming {} -> {}", tmp.display(), path.display()))?;
Ok(())
}
pub fn find(&self, host: &str, owner: &str, name: &str) -> Option<&Repo> {
self.repos
.iter()
.find(|r| r.host == host && r.owner == owner && r.name == name)
}
pub fn find_mut(&mut self, host: &str, owner: &str, name: &str) -> Option<&mut Repo> {
self.repos
.iter_mut()
.find(|r| r.host == host && r.owner == owner && r.name == name)
}
pub fn find_by_path(
&self,
host: &str,
owner: &str,
name: &str,
path: Option<&Path>,
) -> Option<&Repo> {
self.repos.iter().find(|r| {
r.host == host && r.owner == owner && r.name == name && r.path.as_deref() == path
})
}
pub fn find_mut_by_path(
&mut self,
host: &str,
owner: &str,
name: &str,
path: Option<&Path>,
) -> Option<&mut Repo> {
self.repos.iter_mut().find(|r| {
r.host == host && r.owner == owner && r.name == name && r.path.as_deref() == path
})
}
pub fn add(&mut self, repo: Repo) -> Result<()> {
if self
.find_by_path(&repo.host, &repo.owner, &repo.name, repo.path.as_deref())
.is_some()
{
let where_clause = match &repo.path {
Some(p) => format!(" at {}", p.display()),
None => String::new(),
};
bail!("repo {}{where_clause} already on the shelf", repo.slug());
}
self.repos.push(repo);
Ok(())
}
pub fn remove(&mut self, host: &str, owner: &str, name: &str) -> Option<Repo> {
let pos = self
.repos
.iter()
.position(|r| r.host == host && r.owner == owner && r.name == name)?;
Some(self.repos.remove(pos))
}
pub fn len(&self) -> usize {
self.repos.len()
}
pub fn is_empty(&self) -> bool {
self.repos.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn sample_repo(name: &str) -> Repo {
Repo::new("github.com", "yukimemi", name)
}
#[test]
fn missing_file_yields_empty_shelf() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("state.toml");
assert!(!target.exists());
let shelf = Shelf::load_from(&target).expect("missing file -> default");
assert_eq!(shelf.version, SHELF_VERSION);
assert!(shelf.repos.is_empty());
}
#[test]
fn save_then_load_round_trip() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("nested").join("state.toml");
let mut shelf = Shelf::default();
shelf
.add({
let mut r = sample_repo("shoka");
r.tags = vec!["rust".into(), "cli".into()];
r.note = Some("the bookshelf itself".into());
r
})
.unwrap();
shelf.add(sample_repo("renri")).unwrap();
shelf
.add({
let mut r = sample_repo("kanade");
r.vcs = Some(VcsDefault::Jj);
r
})
.unwrap();
shelf.save_to(&target).expect("save");
assert!(target.exists(), "save should create the file");
let loaded = Shelf::load_from(&target).expect("load");
assert_eq!(loaded.version, SHELF_VERSION);
assert_eq!(loaded.repos, shelf.repos);
}
#[test]
fn save_skips_optional_fields_for_repos_that_dont_set_them() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("state.toml");
let mut shelf = Shelf::default();
shelf.add(sample_repo("renri")).unwrap();
shelf.save_to(&target).unwrap();
let body = fs::read_to_string(&target).unwrap();
assert!(body.contains("name = \"renri\""));
assert!(
!body.contains("note"),
"no-note repo should not emit a `note =` key, got:\n{body}"
);
assert!(
!body.contains("tags"),
"no-tags repo should not emit a `tags =` key, got:\n{body}"
);
assert!(
!body.contains("vcs"),
"no-vcs-override repo should not emit a `vcs =` key, got:\n{body}"
);
}
#[test]
fn save_uses_atomic_temp_file() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("state.toml");
let mut shelf = Shelf::default();
shelf.add(sample_repo("shoka")).unwrap();
shelf.save_to(&target).unwrap();
assert!(target.exists());
let leftover_tmp: Vec<_> = fs::read_dir(tmp.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("tmp"))
.map(|e| e.file_name())
.collect();
assert!(
leftover_tmp.is_empty(),
"no .tmp siblings should remain after rename, found: {leftover_tmp:?}"
);
let body = fs::read_to_string(&target).unwrap();
assert!(body.contains("\"shoka\""));
}
#[test]
fn save_overwrites_existing_file_without_corruption() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("state.toml");
fs::write(&target, "version = 1\n\n[[repos]]\nhost = \"github.com\"\nowner = \"old\"\nname = \"obsolete\"\n").unwrap();
let mut shelf = Shelf::default();
shelf.add(sample_repo("fresh")).unwrap();
shelf.save_to(&target).unwrap();
let loaded = Shelf::load_from(&target).unwrap();
assert_eq!(loaded.repos.len(), 1);
assert_eq!(loaded.repos[0].name, "fresh");
}
#[test]
fn add_rejects_duplicate_identity_without_path() {
let mut shelf = Shelf::default();
shelf.add(sample_repo("shoka")).unwrap();
let err = shelf.add(sample_repo("shoka")).unwrap_err();
assert!(
err.to_string().contains("already on the shelf"),
"error should mention duplicate: {err}"
);
assert_eq!(shelf.len(), 1, "duplicate add must not mutate");
}
#[test]
fn add_allows_same_triple_with_different_paths() {
let mut shelf = Shelf::default();
let mut a = sample_repo("admintask");
a.path = Some(PathBuf::from(
"/home/u/src/github.com/yukimemi/DeviceManagement",
));
let mut b = sample_repo("admintask");
b.path = Some(PathBuf::from("/home/u/src/old/admintask-backup"));
shelf.add(a).expect("first path-pinned admintask");
shelf
.add(b)
.expect("second path-pinned admintask must coexist");
assert_eq!(shelf.len(), 2);
}
#[test]
fn add_rejects_duplicate_triple_and_path() {
let mut shelf = Shelf::default();
let path = PathBuf::from("/home/u/src/github.com/yukimemi/shoka");
let mut a = sample_repo("shoka");
a.path = Some(path.clone());
let mut b = sample_repo("shoka");
b.path = Some(path);
shelf.add(a).unwrap();
let err = shelf.add(b).unwrap_err();
assert!(
err.to_string().contains("already on the shelf"),
"error should mention duplicate: {err}"
);
assert_eq!(shelf.len(), 1);
}
#[test]
fn find_by_path_distinguishes_clones_of_same_remote() {
let mut shelf = Shelf::default();
let mut a = sample_repo("admintask");
let pa = PathBuf::from("/a/DeviceManagement");
a.path = Some(pa.clone());
let mut b = sample_repo("admintask");
let pb = PathBuf::from("/b/admintask-backup");
b.path = Some(pb.clone());
shelf.add(a).unwrap();
shelf.add(b).unwrap();
let found_a = shelf.find_by_path("github.com", "yukimemi", "admintask", Some(&pa));
assert_eq!(found_a.unwrap().path.as_deref(), Some(pa.as_path()));
let found_b = shelf.find_by_path("github.com", "yukimemi", "admintask", Some(&pb));
assert_eq!(found_b.unwrap().path.as_deref(), Some(pb.as_path()));
assert!(
shelf
.find_by_path("github.com", "yukimemi", "admintask", None)
.is_none()
);
}
#[test]
fn find_and_find_mut_locate_by_triple() {
let mut shelf = Shelf::default();
shelf.add(sample_repo("shoka")).unwrap();
shelf.add(sample_repo("renri")).unwrap();
let r = shelf.find("github.com", "yukimemi", "renri").unwrap();
assert_eq!(r.name, "renri");
let mr = shelf.find_mut("github.com", "yukimemi", "shoka").unwrap();
mr.tags.push("rust".into());
let r2 = shelf.find("github.com", "yukimemi", "shoka").unwrap();
assert_eq!(r2.tags, vec!["rust".to_string()]);
assert!(shelf.find("github.com", "yukimemi", "ghost").is_none());
}
#[test]
fn remove_returns_the_repo_and_shrinks_shelf() {
let mut shelf = Shelf::default();
shelf.add(sample_repo("shoka")).unwrap();
shelf.add(sample_repo("renri")).unwrap();
let removed = shelf.remove("github.com", "yukimemi", "shoka").unwrap();
assert_eq!(removed.name, "shoka");
assert_eq!(shelf.len(), 1);
assert!(shelf.find("github.com", "yukimemi", "shoka").is_none());
assert!(
shelf.remove("github.com", "yukimemi", "ghost").is_none(),
"removing a non-existent repo returns None, not an error"
);
}
#[test]
fn future_version_fails_load() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("state.toml");
fs::write(
&target,
format!(
"version = {}\n\n[[repos]]\nhost = \"github.com\"\nowner = \"o\"\nname = \"n\"\n",
SHELF_VERSION + 1
),
)
.unwrap();
let err = Shelf::load_from(&target).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("newer"),
"error should mention newer schema: {msg}"
);
}
#[test]
fn load_tolerates_missing_optional_fields() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("state.toml");
fs::write(
&target,
r#"version = 1
[[repos]]
host = "github.com"
owner = "yukimemi"
name = "shoka"
"#,
)
.unwrap();
let shelf = Shelf::load_from(&target).expect("load");
assert_eq!(shelf.repos.len(), 1);
let r = &shelf.repos[0];
assert_eq!(r.name, "shoka");
assert_eq!(r.vcs, None);
assert!(r.tags.is_empty());
assert!(r.note.is_none());
}
}