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>,
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>,
}
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) = read_manifest(&path.join(MANIFEST));
Ok(Workspace {
path,
title,
description,
settings,
members,
})
}
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) {
let Ok(text) = std::fs::read_to_string(path) else {
return (None, None, DeckSettings::default());
};
let Ok(manifest) = toml::from_str::<Manifest>(&text) else {
return (None, None, DeckSettings::default());
};
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),
)
}
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
}
#[cfg(test)]
mod tests {
use super::*;
use crate::answer::Mode;
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]\nmode = \"typing\"\ndirection = \"both\"\nunlock-stage = 3\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(Mode::Typing), ws.settings.mode);
assert_eq!(Some(3), ws.settings.unlock_stage); 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.mode.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.mode.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()));
}
}