use std::{
collections::BTreeMap,
io,
path::{Path, PathBuf},
};
use serde::Deserialize;
use crate::deck::DeckSettings;
pub const MANIFEST: &str = "alix.toml";
pub const STORE_FILE: &str = "progress.json";
#[derive(Deserialize, Default)]
struct Manifest {
title: Option<String>,
description: Option<String>,
icon: Option<String>,
store: Option<String>,
source_access: Option<bool>,
#[serde(default)]
defaults: BTreeMap<String, toml::Value>,
}
#[derive(Debug, Clone)]
pub struct Workspace {
pub path: PathBuf,
pub title: Option<String>,
pub description: Option<String>,
pub settings: DeckSettings,
pub members: Vec<PathBuf>,
pub icon: Option<PathBuf>,
}
impl Workspace {
pub fn load(dir: impl AsRef<Path>) -> io::Result<Workspace> {
let path = dir.as_ref().to_path_buf();
let members = members(&path)?;
let (title, description, settings, icon_key) = read_manifest(&path.join(MANIFEST));
let icon = resolve_icon(&path, icon_key.as_deref());
Ok(Workspace {
path,
title,
description,
settings,
members,
icon,
})
}
pub fn display_name(&self) -> String {
self.title.clone().unwrap_or_else(|| {
self.path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
})
}
}
pub(crate) fn read_manifest(
path: &Path,
) -> (Option<String>, Option<String>, DeckSettings, Option<String>) {
let Ok(text) = std::fs::read_to_string(path) else {
return (None, None, DeckSettings::default(), None);
};
let Ok(manifest) = toml::from_str::<Manifest>(&text) else {
return (None, None, DeckSettings::default(), None);
};
let directives: Vec<(String, String)> = manifest
.defaults
.iter()
.map(|(key, value)| (key.clone(), value_to_string(value)))
.collect();
(
manifest.title,
manifest.description,
DeckSettings::from_directives(&directives),
manifest.icon,
)
}
pub fn resolve_icon(dir: &Path, manifest_icon: Option<&str>) -> Option<PathBuf> {
if let Some(rel) = manifest_icon {
let p = dir.join(rel);
if p.is_file() {
return Some(p);
}
}
for ext in ["svg", "png", "jpg", "jpeg", "webp"] {
let p = dir.join("assets").join(format!("icon.{ext}"));
if p.is_file() {
return Some(p);
}
}
None
}
fn value_to_string(value: &toml::Value) -> String {
value
.as_str()
.map(str::to_string)
.unwrap_or_else(|| value.to_string())
}
pub fn is_conventional_non_deck(name: &str) -> bool {
let stem = name.split('.').next().unwrap_or(name);
stem.eq_ignore_ascii_case("readme") || stem.eq_ignore_ascii_case("license")
}
pub fn is_conflict_name(name: &str) -> bool {
name.contains(".sync-conflict-")
|| name.contains("conflicted copy")
|| name.contains(" (Conflict")
|| name.ends_with(".bak")
|| name.ends_with(".orig")
|| name.ends_with('~')
}
pub fn deck_files(dir: &Path) -> Vec<PathBuf> {
members(dir).unwrap_or_default()
}
pub fn file_is_deck(path: &Path) -> bool {
match std::fs::read_to_string(path) {
Ok(text) => crate::parser::is_deck_content(&text),
Err(_) => true,
}
}
fn members(dir: &Path) -> io::Result<Vec<PathBuf>> {
members_where(dir, file_is_deck)
}
pub(crate) fn members_where(
dir: &Path,
mut is_deck: impl FnMut(&Path) -> bool,
) -> io::Result<Vec<PathBuf>> {
let mut paths: Vec<PathBuf> = std::fs::read_dir(dir)?
.filter_map(|r| r.ok().map(|e| e.path()))
.filter(|p| p.is_file() && p.extension().is_some_and(|e| e == "md"))
.filter(|p| {
!p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| is_conventional_non_deck(n) || is_conflict_name(n))
})
.filter(|p| is_deck(p))
.collect();
paths.sort();
Ok(paths)
}
pub fn is_workspace(path: &Path) -> bool {
path.join(MANIFEST).is_file() && has_decks(path)
}
pub fn has_decks(path: &Path) -> bool {
path.is_dir() && members(path).map(|m| !m.is_empty()).unwrap_or(false)
}
pub fn store_path(dir: &Path) -> PathBuf {
match manifest_store(dir) {
Some(store) if Path::new(&store).is_absolute() => PathBuf::from(store),
Some(store) => dir.join(store),
None => dir.join(STORE_FILE),
}
}
pub fn root_store_path(dir: &Path) -> PathBuf {
if is_workspace(dir) {
store_path(dir)
} else {
dir.join(STORE_FILE)
}
}
fn manifest_store(dir: &Path) -> Option<String> {
let text = std::fs::read_to_string(dir.join(MANIFEST)).ok()?;
toml::from_str::<Manifest>(&text).ok()?.store
}
pub fn manifest_source_access(dir: &Path) -> Option<bool> {
let text = std::fs::read_to_string(dir.join(MANIFEST)).ok()?;
toml::from_str::<Manifest>(&text).ok()?.source_access
}
pub fn manifest_icon(dir: &Path) -> Option<String> {
let text = std::fs::read_to_string(dir.join(MANIFEST)).ok()?;
toml::from_str::<Manifest>(&text).ok()?.icon
}
pub fn set_deadline(dir: &Path, date: Option<chrono::NaiveDate>) -> anyhow::Result<()> {
use anyhow::{Context, bail};
let path = dir.join(crate::config::LOCAL_MANIFEST);
if date.is_none() && !path.is_file() {
return Ok(());
}
let text = std::fs::read_to_string(&path).unwrap_or_default();
let mut doc: toml_edit::DocumentMut = text
.parse()
.with_context(|| format!("cannot parse {}", path.display()))?;
match date {
Some(d) => {
if let Some(review) = doc.get("review")
&& review.as_table().is_none()
&& review.as_inline_table().is_none()
{
bail!("[review] in {} is not a table", path.display());
}
if !doc.contains_key("review") {
doc["review"] = toml_edit::table();
}
doc["review"]["deadline"] = toml_edit::value(d.format("%Y-%m-%d").to_string());
}
None => {
if let Some(review) = doc.get_mut("review") {
if let Some(table) = review.as_table_mut() {
table.remove("deadline");
} else if let Some(inline) = review.as_inline_table_mut() {
inline.remove("deadline");
}
}
}
}
let tmp = path.with_extension("toml.tmp");
std::fs::write(&tmp, doc.to_string())
.and_then(|()| std::fs::rename(&tmp, &path))
.with_context(|| format!("cannot write {}", path.display()))
}
pub fn set_deadline_str(dir: &Path, date: Option<&str>) -> anyhow::Result<()> {
use anyhow::Context;
let parsed = date
.map(|d| {
chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d")
.with_context(|| format!("not a YYYY-MM-DD date: {d}"))
})
.transpose()?;
set_deadline(dir, parsed)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::depth::Reveal;
fn write(path: &Path, text: &str) {
std::fs::write(path, text).unwrap();
}
#[test]
fn load_discovers_members_and_parses_manifest() {
let dir = tempfile::tempdir().unwrap();
write(&dir.path().join("a.md"), "## a\n1\n");
write(&dir.path().join("b.md"), "## b\n2\n");
write(
&dir.path().join(MANIFEST),
"title = \"English\"\ndescription = \"everyday vocab\"\n\n[defaults]\nreveal = \"line\"\ndirection = \"both\"\n",
);
let ws = Workspace::load(dir.path()).unwrap();
assert_eq!(Some("English".to_string()), ws.title);
assert_eq!(Some("everyday vocab".to_string()), ws.description);
assert_eq!("English", ws.display_name());
assert_eq!(Some(Reveal::Line), ws.settings.reveal);
let names: Vec<_> = ws
.members
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
.collect();
assert_eq!(vec!["a.md".to_string(), "b.md".to_string()], names);
}
#[test]
fn manifest_optional_title_defaults_to_folder_name() {
let dir = tempfile::tempdir().unwrap();
let folder = dir.path().join("rust");
std::fs::create_dir(&folder).unwrap();
write(&folder.join("a.md"), "## a\n1\n");
let ws = Workspace::load(&folder).unwrap();
assert_eq!(None, ws.title);
assert_eq!("rust", ws.display_name());
assert!(ws.settings.reveal.is_none());
assert_eq!(1, ws.members.len());
}
#[test]
fn malformed_manifest_is_forgiving() {
let dir = tempfile::tempdir().unwrap();
write(&dir.path().join("a.md"), "## a\n1\n");
write(&dir.path().join(MANIFEST), "this is not = = valid toml\n");
let ws = Workspace::load(dir.path()).unwrap();
assert_eq!(None, ws.title);
assert!(ws.settings.reveal.is_none());
assert_eq!(1, ws.members.len());
}
#[test]
fn is_workspace_requires_a_deck() {
let dir = tempfile::tempdir().unwrap();
let empty = dir.path().join("empty");
std::fs::create_dir(&empty).unwrap();
assert!(!is_workspace(&empty));
write(&empty.join("a.md"), "## a\n1\n");
assert!(has_decks(&empty));
assert!(!is_workspace(&empty));
write(&empty.join(MANIFEST), "title = \"x\"\n");
assert!(is_workspace(&empty));
let file = dir.path().join("loose.md");
write(&file, "## a\n1\n");
assert!(!is_workspace(&file));
assert!(!has_decks(&file));
}
#[test]
fn members_exclude_prose_but_keep_header_only_stubs() {
let dir = tempfile::tempdir().unwrap();
write(&dir.path().join("deck.md"), "## q\na\n");
write(&dir.path().join("stub.md"), "---\ntrace: a walk\n---\n");
write(&dir.path().join("notes.md"), "# Notes\n\njust prose\n");
let names: Vec<String> = members(dir.path())
.unwrap()
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
.collect();
assert_eq!(vec!["deck.md".to_string(), "stub.md".to_string()], names);
}
#[test]
fn a_folder_of_only_prose_has_no_decks() {
let dir = tempfile::tempdir().unwrap();
let folder = dir.path().join("prose");
std::fs::create_dir(&folder).unwrap();
write(
&folder.join("notes.md"),
"# Notes\n\njust prose, no cards\n",
);
assert!(!has_decks(&folder));
}
#[test]
fn store_path_defaults_to_progress_json_in_the_workspace() {
let dir = tempfile::tempdir().unwrap();
write(&dir.path().join(MANIFEST), "title = \"W\"\n");
assert_eq!(dir.path().join("progress.json"), store_path(dir.path()));
let bare = dir.path().join("bare");
std::fs::create_dir(&bare).unwrap();
assert_eq!(bare.join("progress.json"), store_path(&bare));
}
#[test]
fn store_path_honors_a_relative_or_absolute_override() {
let dir = tempfile::tempdir().unwrap();
write(&dir.path().join(MANIFEST), "store = \"sub/p.json\"\n");
assert_eq!(dir.path().join("sub/p.json"), store_path(dir.path()));
let abs = if cfg!(windows) {
"C:/p.json"
} else {
"/tmp/p.json"
};
write(&dir.path().join(MANIFEST), &format!("store = \"{abs}\"\n"));
assert_eq!(PathBuf::from(abs), store_path(dir.path()));
}
#[test]
fn manifest_source_access_override() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(None, manifest_source_access(dir.path()));
write(&dir.path().join(MANIFEST), "title = \"W\"\n");
assert_eq!(None, manifest_source_access(dir.path()));
write(&dir.path().join(MANIFEST), "source_access = true\n");
assert_eq!(Some(true), manifest_source_access(dir.path()));
write(&dir.path().join(MANIFEST), "source_access = false\n");
assert_eq!(Some(false), manifest_source_access(dir.path()));
}
#[test]
fn root_store_path_uses_in_folder_progress_for_a_plain_folder() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(
root_store_path(dir.path()),
dir.path().join("progress.json")
);
}
#[test]
fn root_store_path_honors_a_workspace_store_override() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("d.md"), "## Q\nA\n").unwrap();
std::fs::write(
dir.path().join("alix.toml"),
"title = \"W\"\nstore = \"custom.json\"\n",
)
.unwrap();
assert_eq!(root_store_path(dir.path()), dir.path().join("custom.json"));
assert_eq!(root_store_path(dir.path()), store_path(dir.path()));
}
#[test]
fn resolve_icon_prefers_the_manifest_key_then_the_convention() {
let dir = tempfile::tempdir().unwrap();
let assets = dir.path().join("assets");
std::fs::create_dir_all(&assets).unwrap();
assert_eq!(resolve_icon(dir.path(), None), None);
std::fs::write(assets.join("icon.svg"), "<svg/>").unwrap();
assert_eq!(
resolve_icon(dir.path(), None),
Some(assets.join("icon.svg"))
);
std::fs::write(assets.join("logo.png"), b"x").unwrap();
assert_eq!(
resolve_icon(dir.path(), Some("assets/logo.png")),
Some(assets.join("logo.png"))
);
assert_eq!(
resolve_icon(dir.path(), Some("assets/nope.png")),
Some(assets.join("icon.svg"))
);
}
#[test]
fn set_deadline_creates_updates_and_clears_the_key() {
let dir = tempfile::tempdir().unwrap();
let date = chrono::NaiveDate::from_ymd_opt(2026, 9, 1).unwrap();
set_deadline(dir.path(), Some(date)).unwrap();
let text = std::fs::read_to_string(dir.path().join(crate::config::LOCAL_MANIFEST)).unwrap();
assert!(text.contains("deadline = \"2026-09-01\""));
let moved = chrono::NaiveDate::from_ymd_opt(2026, 10, 1).unwrap();
set_deadline(dir.path(), Some(moved)).unwrap();
let text = std::fs::read_to_string(dir.path().join(crate::config::LOCAL_MANIFEST)).unwrap();
assert!(text.contains("2026-10-01") && !text.contains("2026-09-01"));
set_deadline(dir.path(), None).unwrap();
let text = std::fs::read_to_string(dir.path().join(crate::config::LOCAL_MANIFEST)).unwrap();
assert!(!text.contains("deadline"));
}
#[test]
fn set_deadline_preserves_comments_and_other_keys_byte_for_byte() {
let dir = tempfile::tempdir().unwrap();
let scaffold = "# Personal pacing for THIS workspace\n\n[review]\n\n# retention = 0.9 # FSRS target\nretention = 0.85\n";
std::fs::write(dir.path().join(crate::config::LOCAL_MANIFEST), scaffold).unwrap();
let date = chrono::NaiveDate::from_ymd_opt(2026, 9, 1).unwrap();
set_deadline(dir.path(), Some(date)).unwrap();
let text = std::fs::read_to_string(dir.path().join(crate::config::LOCAL_MANIFEST)).unwrap();
assert!(text.contains("# Personal pacing for THIS workspace"));
assert!(text.contains("# retention = 0.9 # FSRS target"));
assert!(text.contains("retention = 0.85"));
assert!(text.contains("deadline = \"2026-09-01\""));
set_deadline(dir.path(), None).unwrap();
let after =
std::fs::read_to_string(dir.path().join(crate::config::LOCAL_MANIFEST)).unwrap();
assert_eq!(
scaffold, after,
"clearing restores the file byte-identically"
);
}
#[test]
fn set_deadline_errors_instead_of_panicking_when_review_is_not_a_table() {
let dir = tempfile::tempdir().unwrap();
let manifest = dir.path().join(crate::config::LOCAL_MANIFEST);
let scaffold = "review = 5\n";
std::fs::write(&manifest, scaffold).unwrap();
let date = chrono::NaiveDate::from_ymd_opt(2026, 9, 1).unwrap();
let result = set_deadline(dir.path(), Some(date));
assert!(
result.is_err(),
"a non-table [review] must error, not panic"
);
let after = std::fs::read_to_string(&manifest).unwrap();
assert_eq!(
scaffold, after,
"a failed set must leave the file untouched"
);
}
#[test]
fn clearing_a_deadline_without_a_manifest_creates_nothing() {
let dir = tempfile::tempdir().unwrap();
let manifest = dir.path().join(crate::config::LOCAL_MANIFEST);
assert!(!manifest.is_file());
set_deadline(dir.path(), None).unwrap();
assert!(
!manifest.is_file(),
"clearing with no manifest must be a true no-op"
);
}
#[test]
fn sync_conflict_names_are_never_decks() {
assert!(is_conflict_name("deck.sync-conflict-20260101-abcdef.md"));
assert!(is_conflict_name("deck (conflicted copy 2026-01-01).md"));
assert!(is_conflict_name(
"deck (Alex's conflicted copy 2026-07-19).md"
));
assert!(is_conflict_name("deck (Conflict).md"));
assert!(is_conflict_name("deck.md.bak"));
assert!(is_conflict_name("deck.md.orig"));
assert!(is_conflict_name("deck.md~"));
assert!(!is_conflict_name("deck.md"));
assert!(!is_conflict_name("my-syncthing-notes.md"));
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("real.md"), "## q\na\n").unwrap();
std::fs::write(
dir.path().join("real.sync-conflict-20260101-abcdef.md"),
"## q\na\n",
)
.unwrap();
std::fs::write(
dir.path().join("real (conflicted copy 2026).md"),
"## q\na\n",
)
.unwrap();
std::fs::write(dir.path().join("real.md.bak"), "## q\na\n").unwrap();
let names: Vec<String> = deck_files(dir.path())
.iter()
.filter_map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
.collect();
assert_eq!(vec!["real.md".to_string()], names);
}
#[test]
fn readme_and_license_are_not_decks() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("real.md"), "## q\na\n").unwrap();
std::fs::write(dir.path().join("README.md"), "about this folder\n").unwrap();
std::fs::write(dir.path().join("LICENSE.md"), "MIT\n").unwrap();
std::fs::write(dir.path().join("license.md"), "lower-case too\n").unwrap();
let ws = Workspace::load(dir.path()).unwrap();
let names: Vec<String> = ws
.members
.iter()
.filter_map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
.collect();
assert_eq!(vec!["real.md".to_string()], names);
}
}