1use std::path::{Path, PathBuf};
4
5use sva_core::CliError;
6
7pub const FILES: [(&str, &str); 14] = [
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 score degree and `st` offset is measured from, so transposing is editing this note | Neglects: the scale a degree counts in, which variables/scale names | IO: () -> hertz | Tags: key
27C3
28",
29 ),
30 (
31 "variables/scale",
32 r"; Models: the scale every `deg` counts in, a ref to one file of cents under scales/, so changing mode is editing this ref | Neglects: the tonic, which variables/key holds | IO: () -> a scale | Tags: scale
33@../scales/major
34",
35 ),
36 (
37 "scales/major",
38 r"; Models: the major scale, one row per degree above the tonic in cents, the last row the octave it repeats at | Neglects: which tonic, which variables/key holds | IO: () -> cents per degree | Tags: scale, major
39200
40400
41500
42700
43900
441100
451200
46",
47 ),
48 (
49 "scales/minor",
50 r"; Models: the natural minor scale, one row per degree above the tonic in cents, the last row the octave it repeats at | Neglects: which tonic, which variables/key holds | IO: () -> cents per degree | Tags: scale, minor
51200
52300
53500
54700
55800
561000
571200
58",
59 ),
60 (
61 "voice/tone",
62 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
63f0 = @../variables/key
64vel = 0.2
65len = 0.5s
66crop(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)
67",
68 ),
69 (
70 "lib/env",
71 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
72attack = 0.005s
73decay = 0.25s
74fade = 0.05s
75len = 0.5s
76crop(exp(-t/decay), 0s, len, rise=attack, fall=fade)
77",
78 ),
79 (
80 "voice/note",
81 r"; Models: one played note, a timbre under an envelope | Neglects: which pitch and when, which the scores under score/ state | IO: (t, f0, vel, len) -> amplitude | Tags: voice, note
82f0 = @../variables/key
83vel = 0.2
84len = 0.5s
85@../voice/tone(t, f0=f0, vel=vel, len=len) * @../lib/env(t, len=len)
86",
87 ),
88 (
89 "score/chord",
90 r#"{"at": "0b", "node": "@../voice/tone", "deg": 1}
91{"at": "0b", "node": "@../voice/tone", "deg": 3}
92{"at": "0b", "node": "@../voice/tone", "deg": 5}
93"#,
94 ),
95 (
96 "score/phrase",
97 r#"{"at": "0b", "node": "@../voice/note", "deg": 8, "len": "0.25b"}
98{"at": "0.25b", "node": "@../voice/note", "deg": 5, "len": "0.25b"}
99{"at": "0.5b", "node": "@../voice/note", "deg": 3, "len": "0.25b"}
100{"at": "0.75b", "node": "@../voice/note", "deg": 5, "len": "0.25b"}
101{"at": "1b", "node": "@../voice/note", "deg": 6, "len": "0.25b"}
102{"at": "1.25b", "node": "@../voice/note", "deg": 5, "len": "0.25b"}
103{"at": "1.5b", "node": "@../voice/note", "deg": 3, "len": "0.25b"}
104{"at": "1.75b", "node": "@../voice/note", "deg": 1, "len": "0.25b"}
105"#,
106 ),
107 (
108 "perc/hat",
109 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
110gain = 0.0005
111crop(gain*noise(1, period=0.5, color=1), 0s, 0.4s, rise=0.002s, fall=0.2s)
112",
113 ),
114 (
115 "fx/glue",
116 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
117crop(sample(0.5*@../score/chord(t) + @../score/phrase(t)) + 0.3*self[idx(t) - 1], 0s, 2b)
118",
119 ),
120 (
121 "master",
122 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 scores 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
123crop(0.6*(@fx/glue(t) + sample(@perc/hat(t)) + sample(@perc/hat(t - 1b))), 0s, 2b)
124",
125 ),
126];
127
128pub const NEXT: [(&str, &str); 9] = [
132 (
133 "sva-cli lint",
134 "structure, comments, scores, key, whether each node plays bare; refuses before any audio",
135 ),
136 (
137 "sva-cli builtins",
138 "the whole vocabulary; nothing outside it parses",
139 ),
140 (
141 "sva-cli trace master",
142 "what master reads, and why it is samples",
143 ),
144 (
145 "sva-cli trace score/phrase",
146 "a score: one line per note, its sound shifted to `at`, each `deg` counted in variables/scale from variables/key",
147 ),
148 (
149 "sva-cli render 'sample(@voice/tone)' --representation spectrum(peaks=3)",
150 "the three harmonics the lowpass leaves, measured off its samples",
151 ),
152 (
153 "sva-cli render 'sample(@score/chord)' --representation spectrum(peaks=9) --set variables/scale=@../scales/minor",
154 "the triad in minor without editing a file; `--set variables/key=D3` transposes it",
155 ),
156 (
157 "sva-cli render '@perc/hat' --representation atoms",
158 "a noise is a line series, one atom per line, 2 Hz apart at period=0.5",
159 ),
160 (
161 "sva-cli render '@master' --representation ledger(skim=1)",
162 "per-node rms, peak, clipped",
163 ),
164 (
165 "sva-cli render '@master' --representation samples=/tmp/song.wav --rate 48000",
166 "the audio itself, at whatever rate you name",
167 ),
168];
169
170#[derive(Debug)]
171pub struct Scaffolded {
172 pub root: PathBuf,
173 pub files: Vec<String>,
174}
175
176pub fn scaffold(
181 dir: &Path,
182 name: &str,
183 idempotency_key: Option<&str>,
184) -> Result<Scaffolded, CliError> {
185 if name.is_empty() || name.contains('/') || name.contains(std::path::MAIN_SEPARATOR) {
186 return Err(CliError::usage(
187 "new.bad_name",
188 format!("`{name}` must be a single directory name, not a path"),
189 ));
190 }
191 let root = dir.join(name);
192 if root.exists() {
193 let key = idempotency_key.ok_or_else(|| CliError::Conflict {
194 by: "new",
195 message: format!(
196 "{} already exists; `new` never overwrites a composition. State \
197 `--idempotency-key <key>` to have a retry of your own create succeed instead",
198 root.display()
199 ),
200 })?;
201 let taken = || CliError::Conflict {
202 by: "new",
203 message: format!(
204 "{} already exists and is not what `--idempotency-key {key}` scaffolded; \
205 `new` never overwrites a composition",
206 root.display()
207 ),
208 };
209 if std::fs::read_to_string(key_path(dir, name)).ok().as_deref() != Some(key) {
210 return Err(taken());
211 }
212 if !holds_scaffold(&root) {
213 return Err(taken());
214 }
215 return Ok(Scaffolded {
216 root,
217 files: FILES.iter().map(|(path, _)| path.to_string()).collect(),
218 });
219 }
220 let staging = dir.join(format!(".{name}.sva-new-{}", std::process::id()));
222 let _ = std::fs::remove_dir_all(&staging);
223 let files = staged(&staging).inspect_err(|_| {
224 let _ = std::fs::remove_dir_all(&staging);
225 })?;
226 std::fs::rename(&staging, &root).map_err(|e| {
227 let _ = std::fs::remove_dir_all(&staging);
228 CliError::Io(format!("could not put {} in place: {e}", root.display()))
229 })?;
230 if let Some(key) = idempotency_key {
231 std::fs::write(key_path(dir, name), key).map_err(|e| {
232 let _ = std::fs::remove_dir_all(&root);
233 let _ = std::fs::remove_file(key_path(dir, name));
234 CliError::Io(format!(
235 "could not record the idempotency key beside {}: {e}",
236 root.display()
237 ))
238 })?;
239 }
240 Ok(Scaffolded { root, files })
241}
242
243fn staged(staging: &Path) -> Result<Vec<String>, CliError> {
244 let mut files = Vec::with_capacity(FILES.len());
245 for (path, contents) in FILES {
246 let full = staging.join(path);
247 if let Some(parent) = full.parent() {
248 std::fs::create_dir_all(parent)
249 .map_err(|e| CliError::Io(format!("could not create {}: {e}", parent.display())))?;
250 }
251 std::fs::write(&full, contents)
252 .map_err(|e| CliError::Io(format!("could not write {}: {e}", full.display())))?;
253 files.push(path.to_string());
254 }
255 Ok(files)
256}
257
258fn key_path(dir: &Path, name: &str) -> std::path::PathBuf {
260 dir.join(format!(".{name}.sva-idempotency-key"))
261}
262
263fn holds_scaffold(root: &Path) -> bool {
265 FILES.iter().all(|(path, contents)| {
266 std::fs::read_to_string(root.join(path)).is_ok_and(|held| held == *contents)
267 })
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 fn tmp(name: &str) -> PathBuf {
275 let dir = std::env::temp_dir().join(format!("sva-cli-new-{name}-{:x}", std::process::id()));
276 let _ = std::fs::remove_dir_all(&dir);
277 std::fs::create_dir_all(&dir).unwrap();
278 dir
279 }
280
281 #[test]
282 fn scaffold_writes_every_file_and_reports_it() {
283 let dir = tmp("basic");
284 let scaffolded = scaffold(&dir, "song1", None).unwrap();
285 assert_eq!(scaffolded.root, dir.join("song1"));
286 assert_eq!(scaffolded.files.len(), FILES.len());
287 for (path, contents) in FILES {
288 let on_disk = std::fs::read_to_string(scaffolded.root.join(path)).unwrap();
289 assert_eq!(on_disk, contents);
290 }
291 let _ = std::fs::remove_dir_all(&dir);
292 }
293
294 #[test]
295 fn scaffold_refuses_a_name_that_already_exists() {
296 let dir = tmp("exists");
297 std::fs::create_dir_all(dir.join("song1")).unwrap();
298 let err = scaffold(&dir, "song1", None).unwrap_err();
299 assert!(matches!(err, CliError::Conflict { .. }));
300 assert_eq!(
301 err.exit_code(),
302 4,
303 "a taken name is a conflict, not a fault"
304 );
305 assert!(err.message().contains("already exists"));
306 let _ = std::fs::remove_dir_all(&dir);
307 }
308
309 #[test]
312 fn a_retry_under_the_same_idempotency_key_answers_the_first_call_again() {
313 let dir = tmp("idempotent");
314 let first = scaffold(&dir, "song1", Some("k-1")).expect("a fresh scaffold");
315 let again = scaffold(&dir, "song1", Some("k-1")).expect("a retry over the same tree");
316 assert_eq!(first.root, again.root);
317 assert_eq!(first.files, again.files);
318
319 std::fs::write(first.root.join("master"), "0.0\n").unwrap();
320 let other = scaffold(&dir, "song1", Some("k-2")).unwrap_err();
321 assert!(
322 matches!(other, CliError::Conflict { .. }),
323 "someone else's key is not a retry of this create"
324 );
325
326 std::fs::write(first.root.join("master"), "0.0\n").unwrap();
327 let edited = scaffold(&dir, "song1", Some("k-1")).unwrap_err();
328 assert!(
329 matches!(edited, CliError::Conflict { .. }),
330 "an edited tree is not a retry"
331 );
332
333 let _ = std::fs::remove_dir_all(&dir);
334 }
335
336 #[test]
337 fn scaffold_refuses_a_name_that_is_a_path() {
338 let dir = tmp("pathlike");
339 assert!(matches!(
340 scaffold(&dir, "a/b", None).unwrap_err(),
341 CliError::Usage { .. }
342 ));
343 let _ = std::fs::remove_dir_all(&dir);
344 }
345
346 #[test]
351 fn the_scaffolded_composition_lints_clean_of_every_doc_comment_check() {
352 let dir = tmp("lints-clean");
353 let scaffolded = scaffold(&dir, "song1", None).unwrap();
354 let report = crate::lint::lint(&scaffolded.root, None);
355 assert!(
356 report.is_ok(),
357 "a freshly scaffolded composition must carry a well-formed doc comment on every \
358 node: {}",
359 report.err().map(|e| e.message()).unwrap_or_default()
360 );
361 assert!(
362 report.is_ok_and(|held| held.findings.is_empty()),
363 "and no warning either: the quickstart's very first command must come back silent"
364 );
365 let _ = std::fs::remove_dir_all(&dir);
366 }
367
368 #[test]
370 fn every_next_command_this_build_still_parses() {
371 for (command, why) in NEXT {
372 let argv: Vec<String> = command
373 .split_whitespace()
374 .skip(1)
375 .map(str::to_string)
376 .collect();
377 assert!(
378 crate::parse_args(&argv).is_ok(),
379 "`new` answers `{command}`, which this build refuses"
380 );
381 assert!(!why.is_empty(), "`{command}` is answered with no reason");
382 }
383 }
384
385 #[test]
387 fn no_scaffold_body_writes_what_this_engine_reads_expensively() {
388 for (path, contents) in FILES {
389 let body: String = contents
390 .lines()
391 .filter(|line| !line.trim_start().starts_with(';'))
392 .collect::<Vec<_>>()
393 .join("\n");
394 for banned in [
395 "44100", "48000", "96000", "rand(", "sat(", "tanh(", "min(", "max(", "sum(",
396 "join(",
397 ] {
398 assert!(
399 !body.contains(banned),
400 "`{path}` writes `{banned}`, which the scaffold teaches against"
401 );
402 }
403 }
404 }
405}