sva-cli 0.7.8

The command-line front end for SVA
Documentation
// Concern: writes one reading to the path it named | Non-concern: picking that path (args/), the WAV byte layout (wav.rs) | IO: (Answer, path) -> a written file or CliError

use std::path::Path;

use sva_core::{CliError, Output, Printed, Report, encode, is_wav, query_data};

use crate::wav::{SampleEncoding, write_channels};
use sva_core::success_envelope;

pub fn refuse_inside(dir: &Path, dest: &Path) -> Result<(), CliError> {
    if !under(dir, dest) {
        return Ok(());
    }
    Err(CliError::Usage(format!(
        "{} is inside the composition at {}, where it would be read as a node on the next \
         parse; write it outside the composition",
        dest.display(),
        dir.display()
    )))
}

/// A link at the destination is where the write lands; a path not there yet takes the
/// nearest ancestor that is, and a root that will not resolve proves nothing either way.
fn under(dir: &Path, dest: &Path) -> bool {
    let Ok(root) = dir.canonicalize() else {
        return true;
    };
    if let Ok(real) = dest.canonicalize() {
        return real.starts_with(&root);
    }
    let parent = match dest.parent() {
        Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
        _ => Path::new(".").to_path_buf(),
    };
    let Ok(mut at) = std::path::absolute(parent) else {
        return true;
    };
    loop {
        if let Ok(real) = at.canonicalize() {
            return real.starts_with(&root);
        }
        if !at.pop() {
            return true;
        }
    }
}

pub struct Framing {
    pub target: String,
    pub rate: u32,
    pub bits: Option<i32>,
    pub interval: Option<(f64, f64)>,
    pub profile: &'static str,
    pub encoding: SampleEncoding,
    pub replace: bool,
}

/// A written file does not come back, so `--confirm` is what replaces one.
pub(crate) fn refuse_replacing(dest: &Path, replace: bool) -> Result<(), CliError> {
    match !replace && dest.exists() {
        true => Err(CliError::Conflict {
            by: "destination",
            message: format!(
                "{} already holds a file; pass `--confirm` to replace it",
                dest.display()
            ),
        }),
        false => Ok(()),
    }
}

/// A reading no `Representation` names: its value is already JSON, so only the envelope.
pub fn write_analysis(
    name: &str,
    value: &str,
    dest: &Path,
    framing: &Framing,
) -> Result<(), CliError> {
    if is_wav(dest) {
        return Err(not_audio(name));
    }
    refuse_replacing(dest, framing.replace)?;
    let analyses = [(name.to_string(), value.to_string())];
    let json = success_envelope(
        &query_data(&Report {
            target: &framing.target,
            rate: framing.rate,
            bits: None,
            interval: framing.interval,
            profile: framing.profile,
            label: None,
            written: &[],
            answers: &[],
            analyses: &analyses,
            limit: None,
        }),
        &[],
    );
    std::fs::write(dest, json + "\n")
        .map_err(|e| CliError::Io(format!("could not write {}: {e}", dest.display())))
}

/// Argv refuses this pair at parse time; a library caller passed none, so it is refused here.
fn not_audio(name: &str) -> CliError {
    CliError::Usage(format!(
        "`{name}` is not audio, so it has no samples to write to a `.wav` path"
    ))
}

/// A `.wav` path takes the samples; any other takes the JSON stdout caps.
pub fn write(printed: &Printed, dest: &Path, framing: &Framing) -> Result<(), CliError> {
    refuse_replacing(dest, framing.replace)?;
    if is_wav(dest) {
        let Output::Samples(buffer) = &printed.answer.value else {
            return Err(not_audio(&printed.name));
        };
        let node = &framing.target;
        let held: Vec<Vec<f32>> = (0..buffer.width)
            .map(|c| encode::float32(node, buffer.plane(c)))
            .collect::<Result<_, _>>()?;
        if let SampleEncoding::Pcm(_) = framing.encoding {
            held.iter()
                .try_for_each(|plane| encode::full_scale(node, plane))?;
        }
        let planes: Vec<&[f32]> = held.iter().map(Vec::as_slice).collect();
        return write_channels(&planes, buffer.rate, dest, framing.encoding);
    }
    let json = success_envelope(
        &query_data(&Report {
            target: &framing.target,
            rate: framing.rate,
            bits: framing.bits,
            interval: framing.interval,
            profile: framing.profile,
            label: None,
            written: &[],
            answers: std::slice::from_ref(printed),
            analyses: &[],
            limit: None,
        }),
        &[],
    );
    std::fs::write(dest, json + "\n")
        .map_err(|e| CliError::Io(format!("could not write {}: {e}", dest.display())))
}