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()
})
}
}
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())
}
fn members(dir: &Path) -> 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 == "txt"))
.collect();
paths.sort();
Ok(paths)
}
pub fn is_workspace(path: &Path) -> bool {
has_decks(path) && path.join(MANIFEST).is_file()
}
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),
}
}
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
}
#[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.txt"), "# a\n\t1\n");
write(&dir.path().join("b.txt"), "# b\n\t2\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.txt".to_string(), "b.txt".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.txt"), "# a\n\t1\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.txt"), "# a\n\t1\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.txt"), "# a\n\t1\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.txt");
write(&file, "# a\n\t1\n");
assert!(!is_workspace(&file));
assert!(!has_decks(&file));
}
#[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 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"))
);
}
}