alix 0.5.0

A learning tool built for understanding, not just remembering.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
//! A workspace: a folder that groups decks, sharing directives and a title.
//!
//! Workspaces let related decks (e.g. all the English-vocab decks) live in one
//! folder, be reviewed together, and inherit a common set of directives without
//! repeating them in every file. Membership is **folder-implicit**: a workspace
//! is any folder containing `*.txt` decks. An optional [`MANIFEST`]
//! (`alix.toml`) sets a `title` and a `[defaults]` table of shared directives,
//! mirroring the local [`Config`](crate::config::Config) (`config.toml`) but
//! scoped to the folder. The `[defaults]` keys are the deck directive names,
//! fed through the same interpreter ([`DeckSettings::from_directives`]), then
//! folded below each member deck's own directives (see
//! [`crate::deck::Deck::load_with_defaults`]) — precedence card > deck >
//! workspace > default.

use std::{
    collections::BTreeMap,
    io,
    path::{Path, PathBuf},
};

use serde::Deserialize;

use crate::deck::DeckSettings;

/// The reserved manifest file in a workspace folder. Its `.toml` extension
/// keeps it out of the `*.txt` member scan automatically.
pub const MANIFEST: &str = "alix.toml";

/// The progress store a workspace uses when its manifest declares no `store`
/// override: `progress.json` inside the workspace folder, so progress travels
/// with the workspace.
pub const STORE_FILE: &str = "progress.json";

/// The `alix.toml` manifest: a display `title`, a one-line `description` (e.g.
/// the learning goal the workspace generation was given), an optional `store` path (where
/// this workspace's progress lives), and a `[defaults]` table of shared
/// directives (keyed by directive name). Unknown keys/sections are ignored, so
/// the format stays forgiving and forward-compatible.
#[derive(Deserialize, Default)]
struct Manifest {
    title: Option<String>,
    /// A short description of what the workspace is for (its learning goal).
    description: Option<String>,
    /// An optional icon for this workspace, shown in the picker. A path relative
    /// to the workspace (or absolute). Unset → a conventional `assets/icon.*` is
    /// used if present.
    icon: Option<String>,
    /// Where this workspace keeps its progress (relative to the workspace, or
    /// absolute). `None` → `<workspace>/progress.json`.
    store: Option<String>,
    /// Per-workspace override of the global `[ask] source_access`: when set, it
    /// decides whether the grounded ask-tutor may read this workspace's decks'
    /// source. `None` → inherit the global config.
    source_access: Option<bool>,
    #[serde(default)]
    defaults: BTreeMap<String, toml::Value>,
}

/// A folder of decks reviewed as a unit, with shared directive defaults.
#[derive(Debug, Clone)]
pub struct Workspace {
    /// The workspace folder.
    pub path: PathBuf,
    /// Display title (manifest `title`), or `None` to use the folder name.
    pub title: Option<String>,
    /// A one-line description of the workspace (manifest `description`), or
    /// `None`. A workspace `alix generate` writes the learning goal here.
    pub description: Option<String>,
    /// Shared directive defaults from the manifest, folded below each member
    /// deck's own directives.
    pub settings: DeckSettings,
    /// Member deck paths: the folder's `*.txt` files, sorted by name.
    pub members: Vec<PathBuf>,
    /// The resolved icon file shown in the picker (manifest `icon`, else a
    /// conventional `assets/icon.*`), or `None` for the chevron fallback.
    pub icon: Option<PathBuf>,
}

impl Workspace {
    /// Loads the workspace rooted at `dir`: its `*.txt` members and, if
    /// present, the `alix.toml` manifest (title + shared directives). A
    /// folder without a manifest — or with a malformed one — is still a
    /// workspace, with default settings, so a bad manifest never stops it
    /// from loading.
    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,
        })
    }

    /// The workspace's display name: its manifest `title` if set, else the
    /// folder name.
    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()
        })
    }
}

/// Reads the manifest's title, description, and shared directive defaults. A
/// missing or malformed file yields no title/description and default settings.
/// The `[defaults]` table is interpreted by [`DeckSettings::from_directives`],
/// so its keys mean exactly what the matching `% key: value` deck directives
/// mean.
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,
    )
}

/// Resolve a workspace's picker icon: the manifest `icon = "…"` file if it
/// exists, else a conventional `assets/icon.{svg,png,jpg,jpeg,webp}` (first
/// match), else `None`.
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
}

/// A TOML value as the plain string the directive interpreter expects
/// (`"both"` → `both`, `3` → `3`).
fn value_to_string(value: &toml::Value) -> String {
    value
        .as_str()
        .map(str::to_string)
        .unwrap_or_else(|| value.to_string())
}

/// The `*.txt` decks directly inside `dir`, sorted by name (one level deep).
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)
}

/// `true` if `path` is an **explicit workspace**: a directory with an `alix.toml`
/// manifest *and* at least one `*.txt` deck. A folder of decks without a manifest
/// is a plain "folder" (see [`has_decks`]) — reviewable, but not a workspace.
pub fn is_workspace(path: &Path) -> bool {
    has_decks(path) && path.join(MANIFEST).is_file()
}

/// `true` if `path` is a directory holding at least one `*.txt` deck — a
/// drillable folder in the pickers, whether or not it is a workspace.
pub fn has_decks(path: &Path) -> bool {
    path.is_dir() && members(path).map(|m| !m.is_empty()).unwrap_or(false)
}

/// Where the workspace at `dir` keeps its progress: the manifest's `store` path
/// (relative to `dir`, or absolute), else `<dir>/progress.json`. So an
/// encapsulated workspace's progress travels with the folder, and a deck inside
/// it is tracked separately from the global store. (Callers use this only for
/// directories that are workspaces.)
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),
    }
}

/// The progress store for a **served root folder**: a workspace keeps its own
/// (manifest `store =` respected), a plain folder uses `<dir>/progress.json`.
/// One place for the resolution the launcher and `doctor` both apply to a root.
pub fn root_store_path(dir: &Path) -> PathBuf {
    if is_workspace(dir) {
        store_path(dir)
    } else {
        dir.join(STORE_FILE)
    }
}

/// The raw `store = "..."` value from the workspace's manifest, if any.
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
}

/// The workspace manifest's `source_access` override, if it sets one — a
/// per-workspace `Some(true)`/`Some(false)` that beats the global `[ask]
/// source_access`. `None` (no manifest, malformed, or unset) → inherit global.
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
}

/// The raw `icon = "..."` value from the workspace's manifest, if any.
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
}

/// Sets, moves, or clears (`None`) the workspace's personal `deadline` in its
/// `alix.local.toml`, preserving everything else in the file byte-for-byte
/// (toml_edit). Creates the file when setting into a bare workspace; clearing
/// leaves an existing file in place. Atomic write (tmp + rename).
pub fn set_deadline(dir: &Path, date: Option<chrono::NaiveDate>) -> anyhow::Result<()> {
    use anyhow::{Context, bail};
    let path = dir.join(crate::config::LOCAL_MANIFEST);
    // Clearing a deadline that was never set, with no file to touch, is a
    // true no-op: don't create the manifest as a side effect.
    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) => {
            // A hand-edited `review = 5` (not a table) can't be indexed into
            // safely; error rather than panic on `doc["review"]["deadline"]`.
            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());
            }
            // Ensure we have a proper [review] section, not an inline table.
            if !doc.contains_key("review") {
                doc["review"] = toml_edit::table();
            }
            doc["review"]["deadline"] = toml_edit::value(d.format("%Y-%m-%d").to_string());
        }
        None => {
            // Handle both inline tables and proper tables safely. A non-table
            // `review` (e.g. `review = 5`) has no deadline key to remove, so
            // this is a silent no-op rather than an error.
            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()))
}

#[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);
        // The manifest is not a `.txt`, so it is never a member.
        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");
        // A bad manifest doesn't stop the folder from being a workspace.
        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)); // no decks

        write(&empty.join("a.txt"), "# a\n\t1\n");
        assert!(has_decks(&empty)); // a drillable folder...
        assert!(!is_workspace(&empty)); // ...but not a workspace without a manifest

        write(&empty.join(MANIFEST), "title = \"x\"\n");
        assert!(is_workspace(&empty)); // manifest present → an explicit workspace

        // A plain file is neither.
        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()));
        // No manifest at all → still the in-folder default.
        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();
        // Unset (or no manifest) → None, i.e. inherit the global `[ask]` setting.
        assert_eq!(None, manifest_source_access(dir.path()));
        write(&dir.path().join(MANIFEST), "title = \"W\"\n");
        assert_eq!(None, manifest_source_access(dir.path()));
        // Explicit overrides win.
        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();
        // A workspace is a manifest *and* at least one deck, so write both.
        std::fs::write(dir.path().join("d.txt"), "# Q\n    A\n").unwrap();
        std::fs::write(
            dir.path().join("alix.toml"),
            "title = \"W\"\nstore = \"custom.json\"\n",
        )
        .unwrap();
        // A workspace routes through store_path, so a manifest `store =` wins.
        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();

        // Nothing present → None.
        assert_eq!(resolve_icon(dir.path(), None), None);

        // Convention file present → resolves it.
        std::fs::write(assets.join("icon.svg"), "<svg/>").unwrap();
        assert_eq!(
            resolve_icon(dir.path(), None),
            Some(assets.join("icon.svg"))
        );

        // Manifest key pointing at a real file wins over the convention.
        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"))
        );

        // Manifest key pointing at a missing file falls back to the convention.
        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() {
        // The workspace-init scaffold ships commented docs; editing the deadline
        // must not reformat or eat them (spec A3; the reason toml_edit exists).
        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"
        );
    }
}