Skip to main content

sva_cli/
new.rs

1// Concern: scaffolds a starter composition and the order to read it in | Non-concern: rendering it, or the JSON shape (output.rs) | IO: (dir, name) -> a written tree or CliError
2
3use std::path::{Path, PathBuf};
4
5use sva_core::CliError;
6
7/// `(path under the new directory, contents)`, written in this order so a directory always
8/// exists before a file lands in it. Every body stays a closed form until its own layer's
9/// written `sample()`, and each equation's comment says why it is written that way rather
10/// than the way that costs orders of magnitude more; a score is JSON and carries none.
11pub const FILES: [(&str, &str); 13] = [
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, and the scale a degree counts in, so transposing is editing the note and changing mode the `scale` line | Neglects: the notes themselves, which the scores under score/ place | IO: () -> hertz | Tags: key, scale
27scale = @../scales/major
28C3
29",
30    ),
31    (
32        "scales/major",
33        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 names | IO: () -> cents per degree | Tags: scale, major
34200
35400
36500
37700
38900
391100
401200
41",
42    ),
43    (
44        "scales/minor",
45        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 names | IO: () -> cents per degree | Tags: scale, minor
46200
47300
48500
49700
50800
511000
521200
53",
54    ),
55    (
56        "voice/tone",
57        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
58f0 = @../variables/key
59vel = 0.2
60len = 0.5s
61crop(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)
62",
63    ),
64    (
65        "lib/env",
66        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
67attack = 0.005s
68decay = 0.25s
69fade = 0.05s
70len = 0.5s
71crop(exp(-t/decay), 0s, len, rise=attack, fall=fade)
72",
73    ),
74    (
75        "voice/note",
76        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
77f0 = @../variables/key
78vel = 0.2
79len = 0.5s
80@../voice/tone(t, f0=f0, vel=vel, len=len) * @../lib/env(t, len=len)
81",
82    ),
83    (
84        "score/chord",
85        r#"{"at": "0b", "sound": "@../voice/tone", "deg": 1}
86{"at": "0b", "sound": "@../voice/tone", "deg": 3}
87{"at": "0b", "sound": "@../voice/tone", "deg": 5}
88"#,
89    ),
90    (
91        "score/phrase",
92        r#"{"at": "0b", "sound": "@../voice/note", "deg": 8, "len": "0.25b"}
93{"at": "0.25b", "sound": "@../voice/note", "deg": 5, "len": "0.25b"}
94{"at": "0.5b", "sound": "@../voice/note", "deg": 3, "len": "0.25b"}
95{"at": "0.75b", "sound": "@../voice/note", "deg": 5, "len": "0.25b"}
96{"at": "1b", "sound": "@../voice/note", "deg": 6, "len": "0.25b"}
97{"at": "1.25b", "sound": "@../voice/note", "deg": 5, "len": "0.25b"}
98{"at": "1.5b", "sound": "@../voice/note", "deg": 3, "len": "0.25b"}
99{"at": "1.75b", "sound": "@../voice/note", "deg": 1, "len": "0.25b"}
100"#,
101    ),
102    (
103        "perc/hat",
104        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
105gain = 0.0005
106crop(gain*noise(1, period=0.5, color=1), 0s, 0.4s, rise=0.002s, fall=0.2s)
107",
108    ),
109    (
110        "fx/glue",
111        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
112crop(sample(0.5*@../score/chord(t) + @../score/phrase(t)) + 0.3*self[idx(t) - 1], 0s, 2b)
113",
114    ),
115    (
116        "master",
117        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
118crop(0.6*(@fx/glue(t) + sample(@perc/hat(t)) + sample(@perc/hat(t - 1b))), 0s, 2b)
119",
120    ),
121];
122
123/// `(command, why)`, in the order a stranger should run them: what the composition IS before
124/// what it sounds like. Answered beside the
125/// tree `new` wrote, so the first thing an agent reads is the next thing it should do.
126pub const NEXT: [(&str, &str); 9] = [
127    (
128        "sva-cli lint",
129        "structure, comments, scores, key, whether each node plays bare; refuses before any audio",
130    ),
131    (
132        "sva-cli builtins",
133        "the whole vocabulary; nothing outside it parses",
134    ),
135    (
136        "sva-cli trace master",
137        "what master reads, and why it is samples",
138    ),
139    (
140        "sva-cli trace score/phrase",
141        "a score: one line per note, its sound shifted to `at`, each `deg` counted in the key's scale",
142    ),
143    (
144        "sva-cli render 'sample(@voice/tone)' --representation spectrum(peaks=3)",
145        "the three harmonics the lowpass leaves, measured off its samples",
146    ),
147    (
148        "sva-cli render 'sample(@score/chord)' --representation spectrum(peaks=9)",
149        "the triad follows variables/key: its note transposes, its `scale` line changes mode",
150    ),
151    (
152        "sva-cli render '@perc/hat' --representation atoms",
153        "a noise is a line series, one atom per line, 2 Hz apart at period=0.5",
154    ),
155    (
156        "sva-cli render '@master' --representation ledger(skim=1)",
157        "per-node rms, peak, clipped",
158    ),
159    (
160        "sva-cli render '@master' --representation samples=/tmp/song.wav --rate 48000",
161        "the audio itself, at whatever rate you name",
162    ),
163];
164
165#[derive(Debug)]
166pub struct Scaffolded {
167    pub root: PathBuf,
168    pub files: Vec<String>,
169}
170
171/// Refuses if `<dir>/<name>` already exists, so `new` never overwrites a caller's own work.
172/// An `idempotency_key` is recorded beside the composition and answers the same `Scaffolded`
173/// again when the same key is retried over a tree still holding exactly this scaffold, so a
174/// network retry or an agent restart converges rather than erroring.
175pub fn scaffold(
176    dir: &Path,
177    name: &str,
178    idempotency_key: Option<&str>,
179) -> Result<Scaffolded, CliError> {
180    if name.is_empty() || name.contains('/') || name.contains(std::path::MAIN_SEPARATOR) {
181        return Err(CliError::usage(
182            "new.bad_name",
183            format!("`{name}` must be a single directory name, not a path"),
184        ));
185    }
186    let root = dir.join(name);
187    if root.exists() {
188        let key = idempotency_key.ok_or_else(|| CliError::Conflict {
189            by: "new",
190            message: format!(
191                "{} already exists; `new` never overwrites a composition. State \
192                 `--idempotency-key <key>` to have a retry of your own create succeed instead",
193                root.display()
194            ),
195        })?;
196        let taken = || CliError::Conflict {
197            by: "new",
198            message: format!(
199                "{} already exists and is not what `--idempotency-key {key}` scaffolded; \
200                 `new` never overwrites a composition",
201                root.display()
202            ),
203        };
204        if std::fs::read_to_string(key_path(dir, name)).ok().as_deref() != Some(key) {
205            return Err(taken());
206        }
207        if !holds_scaffold(&root) {
208            return Err(taken());
209        }
210        return Ok(Scaffolded {
211            root,
212            files: FILES.iter().map(|(path, _)| path.to_string()).collect(),
213        });
214    }
215    // Written aside, renamed in, the key last: a create that stopped leaves nothing behind.
216    let staging = dir.join(format!(".{name}.sva-new-{}", std::process::id()));
217    let _ = std::fs::remove_dir_all(&staging);
218    let files = staged(&staging).inspect_err(|_| {
219        let _ = std::fs::remove_dir_all(&staging);
220    })?;
221    std::fs::rename(&staging, &root).map_err(|e| {
222        let _ = std::fs::remove_dir_all(&staging);
223        CliError::Io(format!("could not put {} in place: {e}", root.display()))
224    })?;
225    if let Some(key) = idempotency_key {
226        std::fs::write(key_path(dir, name), key).map_err(|e| {
227            let _ = std::fs::remove_dir_all(&root);
228            let _ = std::fs::remove_file(key_path(dir, name));
229            CliError::Io(format!(
230                "could not record the idempotency key beside {}: {e}",
231                root.display()
232            ))
233        })?;
234    }
235    Ok(Scaffolded { root, files })
236}
237
238fn staged(staging: &Path) -> Result<Vec<String>, CliError> {
239    let mut files = Vec::with_capacity(FILES.len());
240    for (path, contents) in FILES {
241        let full = staging.join(path);
242        if let Some(parent) = full.parent() {
243            std::fs::create_dir_all(parent)
244                .map_err(|e| CliError::Io(format!("could not create {}: {e}", parent.display())))?;
245        }
246        std::fs::write(&full, contents)
247            .map_err(|e| CliError::Io(format!("could not write {}: {e}", full.display())))?;
248        files.push(path.to_string());
249    }
250    Ok(files)
251}
252
253/// Beside the composition, never inside it: every file under a composition root is a node.
254fn key_path(dir: &Path, name: &str) -> std::path::PathBuf {
255    dir.join(format!(".{name}.sva-idempotency-key"))
256}
257
258/// Byte-for-byte, so a retry only succeeds over a tree nothing has edited since.
259fn holds_scaffold(root: &Path) -> bool {
260    FILES.iter().all(|(path, contents)| {
261        std::fs::read_to_string(root.join(path)).is_ok_and(|held| held == *contents)
262    })
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    fn tmp(name: &str) -> PathBuf {
270        let dir = std::env::temp_dir().join(format!("sva-cli-new-{name}-{:x}", std::process::id()));
271        let _ = std::fs::remove_dir_all(&dir);
272        std::fs::create_dir_all(&dir).unwrap();
273        dir
274    }
275
276    #[test]
277    fn scaffold_writes_every_file_and_reports_it() {
278        let dir = tmp("basic");
279        let scaffolded = scaffold(&dir, "song1", None).unwrap();
280        assert_eq!(scaffolded.root, dir.join("song1"));
281        assert_eq!(scaffolded.files.len(), FILES.len());
282        for (path, contents) in FILES {
283            let on_disk = std::fs::read_to_string(scaffolded.root.join(path)).unwrap();
284            assert_eq!(on_disk, contents);
285        }
286        let _ = std::fs::remove_dir_all(&dir);
287    }
288
289    #[test]
290    fn scaffold_refuses_a_name_that_already_exists() {
291        let dir = tmp("exists");
292        std::fs::create_dir_all(dir.join("song1")).unwrap();
293        let err = scaffold(&dir, "song1", None).unwrap_err();
294        assert!(matches!(err, CliError::Conflict { .. }));
295        assert_eq!(
296            err.exit_code(),
297            4,
298            "a taken name is a conflict, not a fault"
299        );
300        assert!(err.message().contains("already exists"));
301        let _ = std::fs::remove_dir_all(&dir);
302    }
303
304    /// The `cli` standard's own requirement for a create verb: a retry succeeds identically
305    /// rather than erroring, so an agent restart converges instead of dead-ending.
306    #[test]
307    fn a_retry_under_the_same_idempotency_key_answers_the_first_call_again() {
308        let dir = tmp("idempotent");
309        let first = scaffold(&dir, "song1", Some("k-1")).expect("a fresh scaffold");
310        let again = scaffold(&dir, "song1", Some("k-1")).expect("a retry over the same tree");
311        assert_eq!(first.root, again.root);
312        assert_eq!(first.files, again.files);
313
314        std::fs::write(first.root.join("master"), "0.0\n").unwrap();
315        let other = scaffold(&dir, "song1", Some("k-2")).unwrap_err();
316        assert!(
317            matches!(other, CliError::Conflict { .. }),
318            "someone else's key is not a retry of this create"
319        );
320
321        std::fs::write(first.root.join("master"), "0.0\n").unwrap();
322        let edited = scaffold(&dir, "song1", Some("k-1")).unwrap_err();
323        assert!(
324            matches!(edited, CliError::Conflict { .. }),
325            "an edited tree is not a retry"
326        );
327
328        let _ = std::fs::remove_dir_all(&dir);
329    }
330
331    #[test]
332    fn scaffold_refuses_a_name_that_is_a_path() {
333        let dir = tmp("pathlike");
334        assert!(matches!(
335            scaffold(&dir, "a/b", None).unwrap_err(),
336            CliError::Usage { .. }
337        ));
338        let _ = std::fs::remove_dir_all(&dir);
339    }
340
341    /// A caller's very first `sva-cli lint` after `new` must not immediately refuse them —
342    /// the scaffold is the canonical example of the doc-comment convention, not an exception.
343    /// `missing-comment`/`multiline-comment`/`malformed-comment` are hard errors now, so a
344    /// violation here would surface as `Err`, not as a `Finding` to filter out.
345    #[test]
346    fn the_scaffolded_composition_lints_clean_of_every_doc_comment_check() {
347        let dir = tmp("lints-clean");
348        let scaffolded = scaffold(&dir, "song1", None).unwrap();
349        let report = crate::lint::lint(&scaffolded.root, None);
350        assert!(
351            report.is_ok(),
352            "a freshly scaffolded composition must carry a well-formed doc comment on every \
353             node: {}",
354            report.err().map(|e| e.message()).unwrap_or_default()
355        );
356        assert!(
357            report.is_ok_and(|held| held.findings.is_empty()),
358            "and no warning either: the quickstart's very first command must come back silent"
359        );
360        let _ = std::fs::remove_dir_all(&dir);
361    }
362
363    /// A `next` line an argv change broke would send a stranger's first move into a refusal.
364    #[test]
365    fn every_next_command_this_build_still_parses() {
366        for (command, why) in NEXT {
367            let argv: Vec<String> = command
368                .split_whitespace()
369                .skip(1)
370                .map(str::to_string)
371                .collect();
372            assert!(
373                crate::parse_args(&argv).is_ok(),
374                "`new` answers `{command}`, which this build refuses"
375            );
376            assert!(!why.is_empty(), "`{command}` is answered with no reason");
377        }
378    }
379
380    /// Nothing the house style refuses outright may be what a first composition teaches.
381    #[test]
382    fn no_scaffold_body_writes_what_this_engine_reads_expensively() {
383        for (path, contents) in FILES {
384            let body: String = contents
385                .lines()
386                .filter(|line| !line.trim_start().starts_with(';'))
387                .collect::<Vec<_>>()
388                .join("\n");
389            for banned in [
390                "44100", "48000", "96000", "rand(", "sat(", "tanh(", "min(", "max(", "sum(",
391                "join(",
392            ] {
393                assert!(
394                    !body.contains(banned),
395                    "`{path}` writes `{banned}`, which the scaffold teaches against"
396                );
397            }
398        }
399    }
400}