|
1 | | -//! Save and load a cover preset as TOML, to reuse a look across articles. |
| 1 | +//! Named presets: save the current look under a name and load any saved one |
| 2 | +//! later. Each preset is a TOML file in the presets directory |
| 3 | +//! (`~/.config/article-cover-art-generator/presets/`, override with |
| 4 | +//! `ACAG_PRESETS_DIR`). |
2 | 5 |
|
3 | 6 | use std::path::{Path, PathBuf}; |
4 | 7 |
|
5 | 8 | use anyhow::{Context, Result}; |
6 | 9 |
|
7 | 10 | use crate::cover::CoverConfig; |
| 11 | +use crate::export::slug; |
8 | 12 |
|
9 | | -/// Default preset location: `~/.config/article-cover-art-generator/preset.toml`. |
10 | | -pub fn default_path() -> PathBuf { |
| 13 | +/// Directory holding the preset files. |
| 14 | +pub fn presets_dir() -> PathBuf { |
| 15 | + if let Some(custom) = std::env::var_os("ACAG_PRESETS_DIR") { |
| 16 | + return PathBuf::from(custom); |
| 17 | + } |
11 | 18 | dirs::config_dir() |
12 | 19 | .unwrap_or_else(|| PathBuf::from(".")) |
13 | 20 | .join("article-cover-art-generator") |
14 | | - .join("preset.toml") |
| 21 | + .join("presets") |
15 | 22 | } |
16 | 23 |
|
17 | | -pub fn save(config: &CoverConfig, path: &Path) -> Result<()> { |
18 | | - if let Some(parent) = path.parent() { |
19 | | - std::fs::create_dir_all(parent) |
20 | | - .with_context(|| format!("creating {}", parent.display()))?; |
21 | | - } |
| 24 | +fn list_in(dir: &Path) -> Vec<String> { |
| 25 | + let mut names: Vec<String> = match std::fs::read_dir(dir) { |
| 26 | + Ok(entries) => entries |
| 27 | + .flatten() |
| 28 | + .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "toml")) |
| 29 | + .filter_map(|entry| { |
| 30 | + entry |
| 31 | + .path() |
| 32 | + .file_stem() |
| 33 | + .and_then(|stem| stem.to_str()) |
| 34 | + .map(str::to_owned) |
| 35 | + }) |
| 36 | + .collect(), |
| 37 | + Err(_) => Vec::new(), |
| 38 | + }; |
| 39 | + names.sort(); |
| 40 | + names |
| 41 | +} |
| 42 | + |
| 43 | +fn save_in(dir: &Path, name: &str, config: &CoverConfig) -> Result<String> { |
| 44 | + std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?; |
| 45 | + let stem = slug(name); |
| 46 | + let path = dir.join(format!("{stem}.toml")); |
22 | 47 | let toml = toml::to_string_pretty(config).context("serializing preset")?; |
23 | | - std::fs::write(path, toml).with_context(|| format!("writing {}", path.display()))?; |
24 | | - Ok(()) |
| 48 | + std::fs::write(&path, toml).with_context(|| format!("writing {}", path.display()))?; |
| 49 | + Ok(stem) |
25 | 50 | } |
26 | 51 |
|
27 | | -pub fn load(path: &Path) -> Result<CoverConfig> { |
| 52 | +fn load_in(dir: &Path, name: &str) -> Result<CoverConfig> { |
| 53 | + let path = dir.join(format!("{name}.toml")); |
28 | 54 | let toml = |
29 | | - std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?; |
| 55 | + std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?; |
30 | 56 | toml::from_str(&toml).context("parsing preset") |
31 | 57 | } |
32 | 58 |
|
| 59 | +fn delete_in(dir: &Path, name: &str) -> Result<()> { |
| 60 | + let path = dir.join(format!("{name}.toml")); |
| 61 | + std::fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?; |
| 62 | + Ok(()) |
| 63 | +} |
| 64 | + |
| 65 | +/// Names of all saved presets, sorted. |
| 66 | +pub fn list() -> Vec<String> { |
| 67 | + list_in(&presets_dir()) |
| 68 | +} |
| 69 | + |
| 70 | +/// Save `config` under `name`; returns the stored (slugged) name. |
| 71 | +pub fn save(name: &str, config: &CoverConfig) -> Result<String> { |
| 72 | + save_in(&presets_dir(), name, config) |
| 73 | +} |
| 74 | + |
| 75 | +/// Load the preset stored under `name`. |
| 76 | +pub fn load(name: &str) -> Result<CoverConfig> { |
| 77 | + load_in(&presets_dir(), name) |
| 78 | +} |
| 79 | + |
| 80 | +/// Delete the preset stored under `name`. |
| 81 | +pub fn delete(name: &str) -> Result<()> { |
| 82 | + delete_in(&presets_dir(), name) |
| 83 | +} |
| 84 | + |
33 | 85 | #[cfg(test)] |
34 | 86 | mod tests { |
35 | 87 | #![allow(clippy::field_reassign_with_default)] |
36 | 88 | use super::*; |
37 | 89 | use crate::design::themes::ThemeName; |
38 | 90 |
|
| 91 | + fn temp_dir(tag: &str) -> PathBuf { |
| 92 | + std::env::temp_dir().join(format!("acag-presets-{tag}-{}", std::process::id())) |
| 93 | + } |
| 94 | + |
39 | 95 | #[test] |
40 | | - fn roundtrips_through_toml() { |
| 96 | + fn save_list_load_and_delete_roundtrip() { |
| 97 | + let dir = temp_dir("roundtrip"); |
| 98 | + std::fs::remove_dir_all(&dir).ok(); |
| 99 | + |
| 100 | + assert!(list_in(&dir).is_empty()); |
| 101 | + |
41 | 102 | let mut config = CoverConfig::default(); |
42 | 103 | config.theme = ThemeName::Ai; |
43 | | - config.grain = 0.3; |
44 | | - config.title = "Reusable look".to_owned(); |
45 | | - let toml = toml::to_string_pretty(&config).unwrap(); |
46 | | - let back: CoverConfig = toml::from_str(&toml).unwrap(); |
47 | | - assert_eq!(back.theme, ThemeName::Ai); |
48 | | - assert_eq!(back.title, "Reusable look"); |
49 | | - assert!((back.grain - 0.3).abs() < 1e-9); |
50 | | - } |
| 104 | + let stored = save_in(&dir, "My LinkedIn Look!", &config).unwrap(); |
| 105 | + assert_eq!(stored, "my-linkedin-look"); |
51 | 106 |
|
52 | | - #[test] |
53 | | - fn saves_and_loads_a_file() { |
54 | | - let path = std::env::temp_dir().join(format!("acag-preset-{}.toml", std::process::id())); |
55 | | - let config = CoverConfig::default(); |
56 | | - save(&config, &path).unwrap(); |
57 | | - let loaded = load(&path).unwrap(); |
58 | | - assert_eq!(loaded.title, config.title); |
59 | | - assert_eq!(loaded.pattern, config.pattern); |
60 | | - std::fs::remove_file(&path).ok(); |
| 107 | + assert_eq!(list_in(&dir), vec!["my-linkedin-look".to_owned()]); |
| 108 | + |
| 109 | + let loaded = load_in(&dir, &stored).unwrap(); |
| 110 | + assert_eq!(loaded.theme, ThemeName::Ai); |
| 111 | + |
| 112 | + delete_in(&dir, &stored).unwrap(); |
| 113 | + assert!(list_in(&dir).is_empty()); |
| 114 | + |
| 115 | + std::fs::remove_dir_all(&dir).ok(); |
61 | 116 | } |
62 | 117 |
|
63 | 118 | #[test] |
64 | | - fn default_path_points_at_config_dir() { |
65 | | - let path = default_path(); |
66 | | - assert!(path.ends_with("preset.toml")); |
67 | | - assert!( |
68 | | - path.to_string_lossy() |
69 | | - .contains("article-cover-art-generator") |
70 | | - ); |
| 119 | + fn list_sorts_and_ignores_non_toml() { |
| 120 | + let dir = temp_dir("sort"); |
| 121 | + std::fs::create_dir_all(&dir).unwrap(); |
| 122 | + save_in(&dir, "zeta", &CoverConfig::default()).unwrap(); |
| 123 | + save_in(&dir, "alpha", &CoverConfig::default()).unwrap(); |
| 124 | + std::fs::write(dir.join("notes.txt"), b"ignore me").unwrap(); |
| 125 | + assert_eq!(list_in(&dir), vec!["alpha".to_owned(), "zeta".to_owned()]); |
| 126 | + std::fs::remove_dir_all(&dir).ok(); |
71 | 127 | } |
72 | 128 |
|
73 | 129 | #[test] |
74 | | - fn load_errors_on_missing_and_invalid_files() { |
75 | | - assert!(load(Path::new("/no/such/acag/preset.toml")).is_err()); |
76 | | - let path = std::env::temp_dir().join(format!("acag-bad-{}.toml", std::process::id())); |
77 | | - std::fs::write(&path, "this = is = not = toml").unwrap(); |
78 | | - assert!(load(&path).is_err()); |
79 | | - std::fs::remove_file(&path).ok(); |
| 130 | + fn load_and_delete_error_on_missing() { |
| 131 | + let dir = temp_dir("missing"); |
| 132 | + std::fs::create_dir_all(&dir).unwrap(); |
| 133 | + assert!(load_in(&dir, "nope").is_err()); |
| 134 | + assert!(delete_in(&dir, "nope").is_err()); |
| 135 | + std::fs::remove_dir_all(&dir).ok(); |
80 | 136 | } |
81 | 137 |
|
82 | 138 | #[test] |
83 | | - fn save_errors_when_parent_is_unusable() { |
84 | | - let blocker = std::env::temp_dir().join(format!("acag-pblock-{}", std::process::id())); |
85 | | - std::fs::write(&blocker, b"x").unwrap(); |
86 | | - let path = blocker.join("preset.toml"); |
87 | | - assert!(save(&CoverConfig::default(), &path).is_err()); |
88 | | - std::fs::remove_file(&blocker).ok(); |
| 139 | + fn presets_dir_honors_env_override() { |
| 140 | + // SAFETY: only this test touches this variable. |
| 141 | + unsafe { std::env::set_var("ACAG_PRESETS_DIR", "/tmp/acag-presets-x") }; |
| 142 | + assert_eq!(presets_dir(), PathBuf::from("/tmp/acag-presets-x")); |
| 143 | + unsafe { std::env::remove_var("ACAG_PRESETS_DIR") }; |
89 | 144 | } |
90 | 145 | } |
0 commit comments