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