1use std::path::{Path, PathBuf};
4
5use sva_core::CliError;
6
7pub const FILES: [(&str, &str); 11] = [
12 (
13 "variables/bpm",
14 r"; Models: the pulse every `b` literal is measured against | Neglects: the meter, which variables/meter states | IO: () -> beats per minute | Tags: tempo
15120
16",
17 ),
18 (
19 "variables/meter",
20 r"; Models: how many beats a bar holds, so `1b` resolves to seconds | Neglects: the pulse itself, which variables/bpm states | IO: () -> beats per bar | Tags: meter
214/4
22",
23 ),
24 (
25 "variables/key",
26 r"; Models: the tonic every `st` offset is measured from, so transposing the piece is editing this one number | Neglects: the mode, which chord/home and grid/phrase-2b spell out in their own offsets | IO: () -> hertz | Tags: key
27C3
28",
29 ),
30 (
31 "voice/tone",
32 r"; Models: one voice's timbre, three harmonics rolled off by a lowpass at the third, held for len | Neglects: the envelope, which lib/env owns; every parameter carries a default and the crop ends it, so a bare `@voice/tone` plays | IO: (t, f0, vel, len) -> amplitude | Tags: voice, harmonics
33f0 = @../variables/key
34vel = 0.2
35len = 0.5s
36crop(lowpass(vel*(sin(2*pi*f0*t) + 0.5*sin(2*pi*2*f0*t) + 0.3333*sin(2*pi*3*f0*t)), 3*f0), 0s, len)
37",
38 ),
39 (
40 "lib/env",
41 r"; Models: one exponential decay under a shouldered crop, the shape every note wears | Neglects: the timbre it multiplies, which voice/tone owns; written as one window and never as a `min`/`max` pair, which would leave no dual and no exact reading | IO: (t, attack, decay, fade, len) -> a gain | Tags: envelope
42attack = 0.005s
43decay = 0.25s
44fade = 0.05s
45len = 0.5s
46crop(exp(-t/decay), 0s, len, rise=attack, fall=fade)
47",
48 ),
49 (
50 "voice/note",
51 r"; Models: one played note, a timbre under an envelope | Neglects: which pitch and when, which chord/home and grid/phrase-2b state | IO: (t, f0, vel, len) -> amplitude | Tags: voice, note
52f0 = @../variables/key
53vel = 0.2
54len = 0.5s
55@../voice/tone(t, f0=f0, vel=vel, len=len) * @../lib/env(t, len=len)
56",
57 ),
58 (
59 "chord/home",
60 r"; Models: the tonic triad, a third and a fifth stacked on variables/key as `st` offsets and never as written frequencies, so editing the key transposes the chord | Neglects: the envelope and the rhythm, which voice/note and grid/phrase-2b own | IO: (t) -> amplitude | Tags: chord, triad
61@../voice/tone(t, f0=@../variables/key*0st) + @../voice/tone(t, f0=@../variables/key*4st) + @../voice/tone(t, f0=@../variables/key*7st)
62",
63 ),
64 (
65 "grid/phrase-2b",
66 r"; Models: eight steps over two bars, one note a step, each pitch an `st` offset off variables/key | Neglects: the timbre and the envelope, which voice/tone and lib/env own | IO: (t) -> amplitude | Tags: grid, phrase
67@../voice/note(t, f0=@../variables/key*12st)
68@../voice/note(t, f0=@../variables/key*7st)
69@../voice/note(t, f0=@../variables/key*4st)
70@../voice/note(t, f0=@../variables/key*7st)
71@../voice/note(t, f0=@../variables/key*9st)
72@../voice/note(t, f0=@../variables/key*7st)
73@../voice/note(t, f0=@../variables/key*4st)
74@../voice/note(t, f0=@../variables/key*0st)
75",
76 ),
77 (
78 "perc/hat",
79 r"; Models: a closed hat -- one noise band under a shouldered crop, its 0.5 s period commensurate with this 2 s bar, its gain small because a noise's own rms is the square root of half its line count | Neglects: pitch, which no hat has; a `noise(...)*exp(...)` tail would cost orders of magnitude more | IO: (t, gain) -> amplitude | Tags: percussion, noise
80gain = 0.0005
81crop(gain*noise(1, period=0.5, color=1), 0s, 0.4s, rise=0.002s, fall=0.2s)
82",
83 ),
84 (
85 "fx/glue",
86 r"; Models: the pitched layers glued by one short feedback -- the pitched chain's one crossing into samples, and the last thing that chain does, because `self` may read only what `sample` has already written | Neglects: the hats, which master sums in beside this under a `sample` of their own; a loop rings on forever, so it is cropped to the two bars it plays | IO: (t) -> amplitude | Tags: fx, feedback
87crop(sample(0.5*@../chord/home(t) + @../grid/phrase-2b(t)) + 0.3*self[idx(t) - 1], 0s, 2b)
88",
89 ),
90 (
91 "master",
92 r"; Models: two bars of the whole piece, each layer sampled on its own | Neglects: nothing it does not name; one closed form over the noise and the grid together costs far more than this, and a mono master broadcasts to any width, so no `join` of a value with itself is written | IO: (t) -> amplitude | Tags: master, arrangement
93crop(0.6*(@fx/glue(t) + sample(@perc/hat(t)) + sample(@perc/hat(t - 1b))), 0s, 2b)
94",
95 ),
96];
97
98pub const NEXT: [(&str, &str); 8] = [
102 (
103 "sva-cli lint",
104 "structure, comments, grid rows, key, whether each node plays bare; refuses before any audio",
105 ),
106 (
107 "sva-cli builtins",
108 "the whole vocabulary; nothing outside it parses",
109 ),
110 (
111 "sva-cli trace master",
112 "what master reads, and why it is samples",
113 ),
114 (
115 "sva-cli render 'sample(@voice/tone)' --representation spectrum(peaks=3)",
116 "the three harmonics the lowpass leaves, measured off its samples",
117 ),
118 (
119 "sva-cli render 'sample(@chord/home)' --representation spectrum(peaks=9)",
120 "the same triad follows variables/key",
121 ),
122 (
123 "sva-cli render '@perc/hat' --representation atoms",
124 "a noise is a line series, one atom per line, 2 Hz apart at period=0.5",
125 ),
126 (
127 "sva-cli render '@master' --representation ledger(skim=1)",
128 "per-node rms, peak, clipped",
129 ),
130 (
131 "sva-cli render '@master' --representation samples=/tmp/song.wav --rate 48000",
132 "the audio itself, at whatever rate you name",
133 ),
134];
135
136#[derive(Debug)]
137pub struct Scaffolded {
138 pub root: PathBuf,
139 pub files: Vec<String>,
140}
141
142pub fn scaffold(
147 dir: &Path,
148 name: &str,
149 idempotency_key: Option<&str>,
150) -> Result<Scaffolded, CliError> {
151 if name.is_empty() || name.contains('/') || name.contains(std::path::MAIN_SEPARATOR) {
152 return Err(CliError::usage(
153 "new.bad_name",
154 format!("`{name}` must be a single directory name, not a path"),
155 ));
156 }
157 let root = dir.join(name);
158 if root.exists() {
159 let key = idempotency_key.ok_or_else(|| CliError::Conflict {
160 by: "new",
161 message: format!(
162 "{} already exists; `new` never overwrites a composition. State \
163 `--idempotency-key <key>` to have a retry of your own create succeed instead",
164 root.display()
165 ),
166 })?;
167 let taken = || CliError::Conflict {
168 by: "new",
169 message: format!(
170 "{} already exists and is not what `--idempotency-key {key}` scaffolded; \
171 `new` never overwrites a composition",
172 root.display()
173 ),
174 };
175 if std::fs::read_to_string(key_path(dir, name)).ok().as_deref() != Some(key) {
176 return Err(taken());
177 }
178 if !holds_scaffold(&root) {
179 return Err(taken());
180 }
181 return Ok(Scaffolded {
182 root,
183 files: FILES.iter().map(|(path, _)| path.to_string()).collect(),
184 });
185 }
186 let staging = dir.join(format!(".{name}.sva-new-{}", std::process::id()));
188 let _ = std::fs::remove_dir_all(&staging);
189 let files = staged(&staging).inspect_err(|_| {
190 let _ = std::fs::remove_dir_all(&staging);
191 })?;
192 std::fs::rename(&staging, &root).map_err(|e| {
193 let _ = std::fs::remove_dir_all(&staging);
194 CliError::Io(format!("could not put {} in place: {e}", root.display()))
195 })?;
196 if let Some(key) = idempotency_key {
197 std::fs::write(key_path(dir, name), key).map_err(|e| {
198 let _ = std::fs::remove_dir_all(&root);
199 let _ = std::fs::remove_file(key_path(dir, name));
200 CliError::Io(format!(
201 "could not record the idempotency key beside {}: {e}",
202 root.display()
203 ))
204 })?;
205 }
206 Ok(Scaffolded { root, files })
207}
208
209fn staged(staging: &Path) -> Result<Vec<String>, CliError> {
210 let mut files = Vec::with_capacity(FILES.len());
211 for (path, contents) in FILES {
212 let full = staging.join(path);
213 if let Some(parent) = full.parent() {
214 std::fs::create_dir_all(parent)
215 .map_err(|e| CliError::Io(format!("could not create {}: {e}", parent.display())))?;
216 }
217 std::fs::write(&full, contents)
218 .map_err(|e| CliError::Io(format!("could not write {}: {e}", full.display())))?;
219 files.push(path.to_string());
220 }
221 Ok(files)
222}
223
224fn key_path(dir: &Path, name: &str) -> std::path::PathBuf {
226 dir.join(format!(".{name}.sva-idempotency-key"))
227}
228
229fn holds_scaffold(root: &Path) -> bool {
231 FILES.iter().all(|(path, contents)| {
232 std::fs::read_to_string(root.join(path)).is_ok_and(|held| held == *contents)
233 })
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 fn tmp(name: &str) -> PathBuf {
241 let dir = std::env::temp_dir().join(format!("sva-cli-new-{name}-{:x}", std::process::id()));
242 let _ = std::fs::remove_dir_all(&dir);
243 std::fs::create_dir_all(&dir).unwrap();
244 dir
245 }
246
247 #[test]
248 fn scaffold_writes_every_file_and_reports_it() {
249 let dir = tmp("basic");
250 let scaffolded = scaffold(&dir, "song1", None).unwrap();
251 assert_eq!(scaffolded.root, dir.join("song1"));
252 assert_eq!(scaffolded.files.len(), FILES.len());
253 for (path, contents) in FILES {
254 let on_disk = std::fs::read_to_string(scaffolded.root.join(path)).unwrap();
255 assert_eq!(on_disk, contents);
256 }
257 let _ = std::fs::remove_dir_all(&dir);
258 }
259
260 #[test]
261 fn scaffold_refuses_a_name_that_already_exists() {
262 let dir = tmp("exists");
263 std::fs::create_dir_all(dir.join("song1")).unwrap();
264 let err = scaffold(&dir, "song1", None).unwrap_err();
265 assert!(matches!(err, CliError::Conflict { .. }));
266 assert_eq!(
267 err.exit_code(),
268 4,
269 "a taken name is a conflict, not a fault"
270 );
271 assert!(err.message().contains("already exists"));
272 let _ = std::fs::remove_dir_all(&dir);
273 }
274
275 #[test]
278 fn a_retry_under_the_same_idempotency_key_answers_the_first_call_again() {
279 let dir = tmp("idempotent");
280 let first = scaffold(&dir, "song1", Some("k-1")).expect("a fresh scaffold");
281 let again = scaffold(&dir, "song1", Some("k-1")).expect("a retry over the same tree");
282 assert_eq!(first.root, again.root);
283 assert_eq!(first.files, again.files);
284
285 std::fs::write(first.root.join("master"), "0.0\n").unwrap();
286 let other = scaffold(&dir, "song1", Some("k-2")).unwrap_err();
287 assert!(
288 matches!(other, CliError::Conflict { .. }),
289 "someone else's key is not a retry of this create"
290 );
291
292 std::fs::write(first.root.join("master"), "0.0\n").unwrap();
293 let edited = scaffold(&dir, "song1", Some("k-1")).unwrap_err();
294 assert!(
295 matches!(edited, CliError::Conflict { .. }),
296 "an edited tree is not a retry"
297 );
298
299 let _ = std::fs::remove_dir_all(&dir);
300 }
301
302 #[test]
303 fn scaffold_refuses_a_name_that_is_a_path() {
304 let dir = tmp("pathlike");
305 assert!(matches!(
306 scaffold(&dir, "a/b", None).unwrap_err(),
307 CliError::Usage { .. }
308 ));
309 let _ = std::fs::remove_dir_all(&dir);
310 }
311
312 #[test]
317 fn the_scaffolded_composition_lints_clean_of_every_doc_comment_check() {
318 let dir = tmp("lints-clean");
319 let scaffolded = scaffold(&dir, "song1", None).unwrap();
320 let report = crate::lint::lint(&scaffolded.root, None);
321 assert!(
322 report.is_ok(),
323 "a freshly scaffolded composition must carry a well-formed doc comment on every \
324 node: {}",
325 report.err().map(|e| e.message()).unwrap_or_default()
326 );
327 assert!(
328 report.is_ok_and(|held| held.findings.is_empty()),
329 "and no warning either: the quickstart's very first command must come back silent"
330 );
331 let _ = std::fs::remove_dir_all(&dir);
332 }
333
334 #[test]
336 fn every_next_command_this_build_still_parses() {
337 for (command, why) in NEXT {
338 let argv: Vec<String> = command
339 .split_whitespace()
340 .skip(1)
341 .map(str::to_string)
342 .collect();
343 assert!(
344 crate::parse_args(&argv).is_ok(),
345 "`new` answers `{command}`, which this build refuses"
346 );
347 assert!(!why.is_empty(), "`{command}` is answered with no reason");
348 }
349 }
350
351 #[test]
353 fn no_scaffold_body_writes_what_this_engine_reads_expensively() {
354 for (path, contents) in FILES {
355 let body: String = contents
356 .lines()
357 .filter(|line| !line.trim_start().starts_with(';'))
358 .collect::<Vec<_>>()
359 .join("\n");
360 for banned in [
361 "44100", "48000", "96000", "rand(", "sat(", "tanh(", "min(", "max(", "sum(",
362 "join(",
363 ] {
364 assert!(
365 !body.contains(banned),
366 "`{path}` writes `{banned}`, which the scaffold teaches against"
367 );
368 }
369 }
370 }
371}