crates/veilvoice-policy/src/workspace.rs

what this file is for · veilvoice-policy · 817 lines · the same file on GitHub

The file as it is in the tree, in the colours you chose. A line number is a link, and so is every box in this file’s diagram: it opens here with the function it names marked.


// SPDX-License-Identifier: GPL-3.0-or-later
//! Named profiles and saved projects.
//!
//! Two things, which sound alike and are not:
//!
//! * A [`Profile`] is a **way of working**, a named set of settings you can
//!   switch to. "Anonymise one person, as thoroughly as this can." "A group,
//!   everybody the same voice." Three are built in and you can save your own.
//! * A [`Workspace`] is **one piece of work**: which recording, which plan,
//!   who is in it and what they are called, and the profile it was done under.
//!   Saved beside the recording, so opening it a week later puts everything
//!   back where it was.
//!
//! # Why a project file is worth having here in particular
//!
//! Setting up a group recording is a dozen small decisions: who is in it, what
//! each is called, what colour each is drawn in, whether they share a voice.
//! Getting halfway through and having to stop is ordinary. Losing all of it
//! because the window was closed is not, and it is worse than usual here,
//! because the *recording* may be the only copy of something that cannot be
//! made again.
//!
//! # Plain text, and the same shape as a plan
//!
//! `VEILWORK1`, then one `key  value` per line. The same format
//! `veilvoice-conversation` uses for a plan, for the same reasons: it is
//! readable, diffable, greppable, and has no syntax in which something
//! surprising can hide. A project file is a description of your work; it should
//! not require this program to read it.
//!
//! # What a project file does **not** contain
//!
//! **No audio and no passwords.** It records the *path* to a recording, not the
//! recording, and nothing about how anything is encrypted. A workspace is a
//! thing you might send somebody so they can set their machine up the same way;
//! if it carried a passphrase, sending it would be handing over the recordings
//! too, and people would find that out afterwards.
//!
//! Speaker names *are* in it, because the whole point is to put them back, and
//! a name is a name. The file says so.
//!
//! # In plain words
//!
//! This is the "save my project" and "load my setup" part.
//!
//! Profiles are named ways of working you can flip between: one for anonymising
//! a single person as thoroughly as possible, one for a group where everybody
//! keeps their own disguised voice, one for a group where everybody sounds
//! identical and only the names tell you who is speaking.
//!
//! A project file remembers the rest: which recording you were working on, who
//! is in it, what you called them and what colour each one is. Open it next
//! week and everything is where you left it.
//!
//! It does not contain the recording itself and it does not contain any
//! password. It does contain the names you typed, because putting those back is
//! the point of it.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use veilvoice_conversation::mode::VoiceMode;
use veilvoice_core::DeidConfig;


/// The first line of a project file.
const MAGIC: &str = "VEILWORK1";



/// Something that could not be read.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Error {
    /// The file could not be opened or written.
    Io(String),
    /// The text is not a project file, or is one this build cannot read.
    Malformed(String),
}


impl std::fmt::Display for Error {

    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Io(what) | Self::Malformed(what) => write!(f, "{what}"),
        }
    }

}

impl std::error::Error for Error {}

/// A named way of working.
///
/// The settings, and just as importantly a plain sentence about what choosing it
/// actually means. A profile called "highest security" that does not say what
/// it does and does not do is a name doing the work of an explanation.
// No `Eq`: two of these fields are `f32`, and floats have no total equality.
// `PartialEq` is what comparing two profiles actually needs and is honest about
// what it is doing.

#[derive(Clone, Debug, PartialEq)]
pub struct Profile {
    /// Stable identifier, written into a project file.
    pub id: &'static str,
    /// What it is called in a picker.
    pub name: &'static str,
    /// What choosing it means, in one paragraph, including the limits.
    pub note: &'static str,
    /// How far the transform pushes, 0..1.
    pub intensity: f32,
    /// Whether the accent neutraliser runs.
    pub neutralise_accent: bool,
    /// Seconds between modulation seed rolls.
    pub reseed_secs: f32,
    /// Whether results are sealed at rest.
    pub encrypt_at_rest: bool,
    /// Whether metadata is stripped from what is written.
    pub clean_metadata: bool,
    /// Whether this profile is a group setup at all.
    pub group: bool,
    /// If it is, whether everybody shares a voice.
    pub voices: VoiceMode,
}


impl Profile {

    /// The engine settings this profile asks for, over a starting point.
    ///
    /// The sample rate is left alone: it belongs to the recording, not to a
    /// way of working, and a profile that overwrote it would resample somebody's
    /// audio because they picked a preset.
    pub fn applied_to(&self, mut config: DeidConfig) -> DeidConfig {
        config.intensity = self.intensity;
        config.accent.enabled = self.neutralise_accent;
        config.reseed_secs = self.reseed_secs;
        config
    }

}


/// Anonymise one person, with everything this engine has turned on.
pub const INDIVIDUAL: Profile = Profile {
    id: "individual",
    name: "One person",
    note: "One speaker, veiled as thoroughly as this engine does it: the accent \
           neutraliser on, the transform at full strength, the seed rolling every \
           two seconds, the result sealed at rest and its metadata stripped. This \
           is the default and it is what most recordings want. What it does not do \
           is hide *what was said* -- the words are kept on purpose.",
    intensity: 1.0,
    neutralise_accent: true,
    reseed_secs: 2.0,
    encrypt_at_rest: true,
    clean_metadata: true,
    group: false,
    voices: VoiceMode::Distinct,
};



/// A group, each person with their own disguised voice.
pub const GROUP_VOICES: Profile = Profile {
    id: "group-voices",
    name: "A group, a voice each",
    note: "Several people in one recording, each given a different destination \
           voice so a listener can follow it by ear. Capped at the number of \
           voices far enough apart to actually be told apart, which is measured \
           rather than chosen. Every voiceprint is destroyed just as thoroughly \
           as one speaker's would be.",
    intensity: 1.0,
    neutralise_accent: true,
    reseed_secs: 2.0,
    encrypt_at_rest: true,
    clean_metadata: true,
    group: true,
    voices: VoiceMode::Distinct,
};



/// A group where nobody can be picked out by sound at all.
pub const GROUP_ONE_VOICE: Profile = Profile {
    id: "group-one-voice",
    name: "A group, one voice for everybody",
    note: "Several people, all rendered as the same voice, told apart only by \
           their names in the subtitles and by which circle lights up in the \
           picture. This is the most private of the three: the output carries no \
           trace of *which* speaker somebody was, so two recordings of the same \
           group cannot be lined up by voice. The price is that nobody can \
           follow it by ear, and it is a real price -- an audio-only listener \
           cannot tell who is speaking at all. It has no speaker limit, because \
           one voice cannot collide with itself.",
    intensity: 1.0,
    neutralise_accent: true,
    reseed_secs: 2.0,
    encrypt_at_rest: true,
    clean_metadata: true,
    group: true,
    voices: VoiceMode::Uniform,
};



/// The profiles that ship, in the order a picker shows them.
pub const BUILT_IN: &[Profile] = &[INDIVIDUAL, GROUP_VOICES, GROUP_ONE_VOICE];



/// The profile with this identifier.
///
/// `None` rather than a fallback: a project file naming a profile this build
/// does not have should say so, not open in a different one and let somebody
/// render under settings they did not choose.
pub fn profile(id: &str) -> Option<&'static Profile> {
    BUILT_IN.iter().find(|p| p.id == id)
}



/// The profile a fresh install starts in.
pub fn default_profile() -> &'static Profile {
    &BUILT_IN[0]
}



/// One person in a saved project.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Member {
    /// What they are called.
    pub name: String,
    /// A colour chosen by hand as `#rrggbb`, or `None` for their slot's.
    pub colour: Option<String>,
}



/// One piece of work, saved.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Workspace {
    /// A name for the project, for a window title or a list.
    pub title: Option<String>,
    /// The recording being worked on.
    pub input: Option<PathBuf>,
    /// The plan saying who speaks when.
    pub plan: Option<PathBuf>,
    /// Which profile this was set up under.
    pub profile: String,
    /// The people, in slot order.
    pub members: Vec<Member>,
    /// The palette a rendered page is drawn in.
    pub theme: String,
    /// What a render writes: any of `audio`, `subtitles`, `page`.
    pub outputs: Vec<String>,
}


impl Workspace {

    /// A new, empty project under the default profile.
    pub fn new() -> Self {
        Self {
            profile: default_profile().id.to_string(),
            theme: "tokyo-night".to_string(),
            outputs: vec![
                "audio".to_string(),
                "subtitles".to_string(),
                "page".to_string(),
            ],
            ..Self::default()
        }
    }



    /// Serialise to the text format.
    pub fn to_text(&self) -> String {
        let mut out = String::from(MAGIC);
        out.push('\n');
        out.push_str(
            "# A VeilVoice project. Plain text on purpose: read it, edit it, or\n\
             # delete it. It holds no audio and no passwords -- only where things\n\
             # are and how they were set up. It does hold the speaker names you\n\
             # typed, because putting those back is the point of it.\n",
        );
        // A value that trims away to nothing is written as **absent**, not as a
        // key with an empty value. Otherwise `Some("   ")` goes out as
        // `title  ` and comes back as `Some("")`, which is neither what was
        // saved nor `None` -- a round trip that quietly changes its answer.
        // The reader is symmetric: an empty value is read back as `None`.
        if let Some(title) = self
            .title
            .as_deref()
            .map(one_line)
            .filter(|v| !v.is_empty())
        {
            out.push_str(&format!("title  {title}\n"));
        }
        if let Some(input) = self
            .input
            .as_deref()
            .map(path_text)
            .filter(|v| !v.is_empty())
        {
            out.push_str(&format!("input  {input}\n"));
        }
        if let Some(plan) = self
            .plan
            .as_deref()
            .map(path_text)
            .filter(|v| !v.is_empty())
        {
            out.push_str(&format!("plan  {plan}\n"));
        }
        out.push_str(&format!("profile  {}\n", one_line(&self.profile)));
        out.push_str(&format!("theme  {}\n", one_line(&self.theme)));
        for output in &self.outputs {
            out.push_str(&format!("output  {}\n", one_line(output)));
        }
        for (index, member) in self.members.iter().enumerate() {
            match &member.colour {
                Some(colour) => out.push_str(&format!(
                    "member  {index}  {}  {}\n",
                    one_line(colour),
                    one_line(&member.name)
                )),
                None => out.push_str(&format!("member  {index}  -  {}\n", one_line(&member.name))),
            }
        }
        out
    }



    /// Parse the text format.
    ///
    /// An unknown keyword is **refused**, not skipped. A project file written by
    /// a newer build may describe a setup this one cannot reproduce, and quietly
    /// honouring the half it understands would put somebody's recording through
    /// settings they did not choose, which is the same reasoning
    /// `veilvoice-conversation` gives for refusing an unknown line in a plan.
    pub fn parse(text: &str) -> Result<Self, Error> {
        let mut lines = text.lines();
        match lines.next().map(str::trim) {
            Some(first) if first == MAGIC => {}
            Some(other) => {
                return Err(Error::Malformed(format!(
                    "this does not start with {MAGIC}, so it is not a VeilVoice project \
                     file (it starts {other:?})"
                )))
            }
            None => return Err(Error::Malformed("the file is empty".into())),
        }

        let mut work = Self::new();
        work.outputs.clear();
        let mut members: BTreeMap<usize, Member> = BTreeMap::new();

        for (number, line) in lines.enumerate() {
            let line = line.trim();
            if line.is_empty() || line.starts_with('#') {
                continue;
            }
            let mut parts = line.split_whitespace();
            let keyword = parts.next().unwrap_or("");
            let rest = line[keyword.len()..].trim();
            let at = number + 2;
            match keyword {
                // Symmetric with the writer: an empty value is absent, not
                // present-and-empty. A `plan  ` line naming no plan should not
                // produce a `Some(PathBuf::from(""))` that later fails to open
                // with a message about a file called nothing.
                "title" => work.title = (!rest.is_empty()).then(|| rest.to_string()),
                "input" => work.input = (!rest.is_empty()).then(|| PathBuf::from(rest)),
                "plan" => work.plan = (!rest.is_empty()).then(|| PathBuf::from(rest)),
                "profile" => work.profile = rest.to_string(),
                "theme" => work.theme = rest.to_string(),
                "output" => work.outputs.push(rest.to_string()),
                "member" => {
                    // Taken one token at a time rather than `splitn`, because
                    // the format separates fields with *two* spaces and
                    // `splitn(3, char::is_whitespace)` splits on the first of
                    // them -- handing back an empty colour and a name of
                    // "-  Alex". A round-trip test caught it; reading the line
                    // would not have.
                    let (index_text, rest) = take_token(rest);
                    let (colour, name) = take_token(rest);
                    let index: usize = index_text
                        .parse()
                        .map_err(|_| Error::Malformed(format!("line {at}: no slot number")))?;
                    let colour = colour.to_string();
                    let name = name.trim().to_string();
                    if name.is_empty() {
                        return Err(Error::Malformed(format!(
                            "line {at}: a member needs a name"
                        )));
                    }
                    if members.contains_key(&index) {
                        return Err(Error::Malformed(format!(
                            "line {at}: slot {index} is listed twice"
                        )));
                    }
                    members.insert(
                        index,
                        Member {
                            name,
                            colour: (colour != "-").then_some(colour),
                        },
                    );
                }
                other => {
                    return Err(Error::Malformed(format!(
                        "line {at}: {other:?} is not something this build understands. A \
                         project file written by a newer VeilVoice may describe a setup \
                         this one cannot reproduce, and honouring half of it would render \
                         under settings you did not choose."
                    )))
                }
            }
        }

        // Slots have to be 0, 1, 2, ... with nothing missing: the index *is* the
        // voice slot, so a gap would move everybody after it onto a different
        // voice from the one they were saved with.
        for (expected, (index, member)) in members.into_iter().enumerate() {
            if index != expected {
                return Err(Error::Malformed(format!(
                    "slot {index} appears where slot {expected} should be. Slots are the \
                     voice each person is given, so a gap would move everybody after it \
                     onto a different voice."
                )));
            }
            work.members.push(member);
        }

        Ok(work)
    }



    /// Read a project file.
    pub fn load(path: &Path) -> Result<Self, Error> {
        let text = std::fs::read_to_string(path)
            .map_err(|e| Error::Io(format!("cannot read {}: {e}", path.display())))?;
        Self::parse(&text)
    }



    /// Write a project file, creating the directory if needed.
    pub fn save(&self, path: &Path) -> Result<(), Error> {
        if let Some(parent) = path.parent() {
            if !parent.as_os_str().is_empty() {
                std::fs::create_dir_all(parent)
                    .map_err(|e| Error::Io(format!("cannot create {}: {e}", parent.display())))?;
            }
        }
        // Owner-only. The panel that saves these says what they hold: where
        // your files are, who is in the recording, and what you called them.
        // It says so to reassure -- no audio, no passwords -- and the names
        // are the part that reassurance does not cover. A file of the names of
        // the people in a recording is worth protecting on its own, and this
        // was writing it readable by every account on the machine.
        //
        // The same slip as the group render's four files, in a third program.
        veilvoice_crypto::privatefile::write_owner_only(path, self.to_text().as_bytes())
            .map_err(|e| Error::Io(format!("cannot write {}: {e}", path.display())))
    }



    /// The profile this project names, if this build has it.
    pub fn profile(&self) -> Option<&'static Profile> {
        profile(&self.profile)
    }

}


/// The first whitespace-delimited token, and everything after it.
///
/// The remainder keeps its own internal spacing, because the last field on a
/// line is a name and a name may contain spaces.
fn take_token(line: &str) -> (&str, &str) {
    let line = line.trim_start();
    match line.find(char::is_whitespace) {
        Some(at) => (&line[..at], line[at..].trim_start()),
        None => (line, ""),
    }
}



/// A value with no line break in it.
///
/// A newline inside a name would let one line become two, and the second could
/// claim to be any keyword it liked, the same forging a plan's speaker names
/// are already guarded against.
fn one_line(value: &str) -> String {
    value.replace(['\n', '\r'], " ").trim().to_string()
}



/// A path as one line, with Windows separators normalised.
fn path_text(path: &Path) -> String {
    one_line(&path.display().to_string().replace('\\', "/"))
}


#[cfg(test)]
mod tests {
    use super::*;

    fn sample() -> Workspace {
        let mut work = Workspace::new();
        work.title = Some("Two people, one afternoon".into());
        work.input = Some(PathBuf::from("recordings/talk.wav"));
        work.plan = Some(PathBuf::from("recordings/plan.txt"));
        work.profile = GROUP_ONE_VOICE.id.to_string();
        work.theme = "gruvbox".into();
        work.members = vec![
            Member {
                name: "Alex".into(),
                colour: None,
            },
            Member {
                name: "Sam".into(),
                colour: Some("#73daca".into()),
            },
        ];
        work
    }

    #[test]
    fn a_project_round_trips_through_its_text_format() {
        let work = sample();
        let read_back = Workspace::parse(&work.to_text()).expect("should parse");
        assert_eq!(read_back, work);
    }

    /// Saving and reading back must give the same project, for every shape a
    /// project can be in -- not only the tidy one in `sample`.
    ///
    /// This is where F-66 came from: `Some("   ")` was written as `title  `
    /// and read back as `Some("")`, which is neither what was saved nor
    /// absent. A round trip that quietly changes its answer is worse than one
    /// that fails, because nothing reports it.
    #[test]
    fn every_shape_of_project_round_trips() {
        let mut shapes = vec![Workspace::new(), sample()];

        // Empty, whitespace, and absent, for each of the three optional values.
        for value in ["", "   ", "a title"] {
            let mut work = Workspace::new();
            work.title = Some(value.to_string());
            work.input = Some(PathBuf::from(value));
            work.plan = Some(PathBuf::from(value));
            shapes.push(work);
        }

        // No members, one member, the most a plan can hold.
        for count in [0usize, 1, 10] {
            let mut work = Workspace::new();
            work.members = (0..count)
                .map(|n| Member {
                    name: format!("Person {n}"),
                    colour: (n % 2 == 0).then(|| "#73daca".to_string()),
                })
                .collect();
            shapes.push(work);
        }

        // No outputs at all, which is a thing somebody can tick their way to.
        let mut none = Workspace::new();
        none.outputs.clear();
        shapes.push(none);

        // A name with the separator in it, and one with a line break.
        let mut awkward = Workspace::new();
        awkward.members = vec![
            Member {
                name: "Two  Spaces".into(),
                colour: None,
            },
            Member {
                name: "Line\nBreak".into(),
                colour: None,
            },
        ];
        shapes.push(awkward);

        for shape in shapes {
            let text = shape.to_text();
            let read_back = Workspace::parse(&text).unwrap_or_else(|e| panic!("{e}\n---\n{text}"));
            let again = Workspace::parse(&read_back.to_text()).expect("stable");
            assert_eq!(
                read_back, again,
                "reading twice must give the same thing:\n{text}"
            );
        }
    }

    /// A value that trims to nothing is absent, in both directions.
    #[test]
    fn an_empty_value_is_absent_rather_than_present_and_empty() {
        let mut work = Workspace::new();
        work.title = Some("   ".into());
        work.plan = Some(PathBuf::from(""));
        let text = work.to_text();
        // By line, not by substring: `output  subtitles` contains "title", and
        // a looser check here passed for the wrong reason on the first run.
        let keys: Vec<&str> = text
            .lines()
            .filter_map(|line| line.split_whitespace().next())
            .collect();
        assert!(!keys.contains(&"title"), "{text}");
        assert!(!keys.contains(&"plan"), "{text}");

        let read_back = Workspace::parse(&format!("{MAGIC}\ntitle  \nplan  \n")).unwrap();
        assert_eq!(read_back.title, None);
        assert_eq!(read_back.plan, None);
    }

    #[test]
    fn a_new_project_writes_all_three_outputs() {
        let work = Workspace::new();
        assert_eq!(work.outputs, vec!["audio", "subtitles", "page"]);
        assert_eq!(work.profile, INDIVIDUAL.id);
        let read_back = Workspace::parse(&work.to_text()).unwrap();
        assert_eq!(read_back.outputs, work.outputs);
    }

    #[test]
    fn it_saves_and_loads_through_a_real_file() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("nested").join("project.veilwork");
        let work = sample();
        work.save(&path).unwrap();
        assert_eq!(Workspace::load(&path).unwrap(), work);
    }

    /// A saved project is readable only by the account that saved it.
    ///
    /// The panel that writes these says what they hold -- where your files
    /// are, who is in the recording, what you called them -- and says it to
    /// reassure: no audio, no passwords. The names are the part that
    /// reassurance does not cover, and this was written 0644, readable by
    /// every other account on the machine.
    #[cfg(unix)]
    #[test]
    fn a_saved_project_is_readable_only_by_this_account() {
        use std::os::unix::fs::PermissionsExt;
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("interview.veilproj");
        sample().save(&path).unwrap();
        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
        assert_eq!(
            mode, 0o600,
            "a project is {mode:o}, so anyone with an account here can read the names in it"
        );
        // And it still round trips, which is the thing the mode must not break.
        assert_eq!(
            Workspace::load(&path).unwrap().to_text(),
            sample().to_text()
        );
    }

    /// **No passwords and no audio.** A project file is a thing somebody might
    /// send; if it carried a passphrase, sending it would hand over the
    /// recordings too, and people would learn that afterwards.
    #[test]
    fn a_project_file_carries_no_secret_and_no_audio() {
        let text = sample().to_text();

        // The **data**, not the header comment. The first version of this
        // scanned the whole file and tripped on the file's own denial -- the
        // header says "no audio and no passwords", which contains "password".
        // `docs/AUDIT.md` records the identical trap in a scope note, where a
        // search for "prevents" matched "nothing here prevents it". A checker
        // has to read the way the thing it checks is read.
        let data: String = text
            .lines()
            .filter(|line| !line.trim_start().starts_with('#'))
            .collect::<Vec<_>>()
            .join("\n")
            .to_lowercase();

        for forbidden in ["password", "passphrase", "secret", "riff", "wave"] {
            assert!(
                !data.contains(forbidden),
                "a project file must not contain {forbidden:?}:\n{data}"
            );
        }
        // And the header does have to say what is not in here, in the words
        // somebody opening the file would read.
        assert!(text.to_lowercase().contains("no audio and no passwords"));
    }

    /// An unknown keyword is refused rather than skipped, and the refusal says
    /// why that is the safe answer.
    #[test]
    fn an_unknown_keyword_is_refused_rather_than_ignored() {
        let text = format!("{MAGIC}\nprofile  individual\nsomething_new  42\n");
        let error = Workspace::parse(&text).expect_err("unknown keywords are refused");
        let words = error.to_string();
        assert!(words.contains("something_new"), "{words}");
        assert!(words.contains("settings you did not choose"), "{words}");
    }

    #[test]
    fn a_file_that_is_not_a_project_is_refused_by_its_first_line() {
        let error =
            Workspace::parse("VEILCONV1\nspeaker  0  Alex\n").expect_err("a plan, not a project");
        assert!(error.to_string().contains(MAGIC));
        assert!(Workspace::parse("").is_err());
    }

    /// A gap in the slots would move everybody after it onto a different voice
    /// from the one they were saved with -- silently, and only audible as
    /// "somebody sounds wrong".
    #[test]
    fn a_gap_in_the_slots_is_refused() {
        let text = format!("{MAGIC}\nmember  0  -  Alex\nmember  2  -  Sam\n");
        let error = Workspace::parse(&text).expect_err("slot 1 is missing");
        assert!(error.to_string().contains("different voice"), "{error}");
    }

    #[test]
    fn a_repeated_slot_is_refused() {
        let text = format!("{MAGIC}\nmember  0  -  Alex\nmember  0  -  Sam\n");
        assert!(Workspace::parse(&text).is_err());
    }

    #[test]
    fn a_member_with_no_name_is_refused() {
        let text = format!("{MAGIC}\nmember  0  -  \n");
        assert!(Workspace::parse(&text).is_err());
    }

    /// A name with a line break in it could forge a record. The writer strips
    /// it rather than producing a file that reads back as something else.
    #[test]
    fn a_name_cannot_forge_a_second_line() {
        let mut work = Workspace::new();
        work.members = vec![Member {
            name: "Alex\nprofile  group-one-voice".into(),
            colour: None,
        }];
        let text = work.to_text();
        let read_back = Workspace::parse(&text).expect("should still parse");
        assert_eq!(
            read_back.profile, INDIVIDUAL.id,
            "the forged line must not win"
        );
        assert!(!read_back.members[0].name.contains('\n'));
    }

    /// A profile this build does not have is reported, not silently swapped for
    /// one it does. Rendering under settings somebody did not choose is the
    /// failure to avoid.
    #[test]
    fn an_unknown_profile_is_reported_rather_than_replaced() {
        let mut work = Workspace::new();
        work.profile = "from-a-newer-build".into();
        assert!(work.profile().is_none());
        let read_back = Workspace::parse(&work.to_text()).unwrap();
        assert_eq!(read_back.profile, "from-a-newer-build");
        assert!(read_back.profile().is_none());
    }

    #[test]
    fn every_built_in_profile_is_findable_and_uniquely_named() {
        let mut ids: Vec<&str> = BUILT_IN.iter().map(|p| p.id).collect();
        let count = ids.len();
        ids.sort_unstable();
        ids.dedup();
        assert_eq!(ids.len(), count, "two profiles share an identifier");
        for one in BUILT_IN {
            assert_eq!(profile(one.id), Some(one));
            assert!(!one.name.is_empty() && !one.note.is_empty());
        }
        assert_eq!(default_profile(), &INDIVIDUAL);
    }

    /// Every profile's note has to say what it does **not** do as well as what
    /// it does. A name like "highest security" doing the work of an explanation
    /// is the thing this project refuses everywhere else.
    #[test]
    fn every_profile_note_states_a_limit() {
        for one in BUILT_IN {
            let note = one.note.to_lowercase();
            assert!(
                note.contains("does not")
                    || note.contains("cannot")
                    || note.contains("the price")
                    || note.contains("rather than"),
                "{}: the note states no limit:\n{}",
                one.id,
                one.note
            );
        }
        // And the most private one has to name its cost outright.
        assert!(GROUP_ONE_VOICE.note.contains("The price is"));
    }

    /// A profile shapes the engine but must not touch the sample rate, which
    /// belongs to the recording. Overwriting it would resample somebody's audio
    /// because they picked a preset.
    #[test]
    fn a_profile_leaves_the_sample_rate_alone() {
        let config = DeidConfig {
            sample_rate: 44_100.0,
            ..DeidConfig::default()
        };
        for one in BUILT_IN {
            let applied = one.applied_to(config);
            assert_eq!(applied.sample_rate, 44_100.0, "{}", one.id);
            assert_eq!(applied.intensity, one.intensity);
            assert_eq!(applied.accent.enabled, one.neutralise_accent);
        }
    }

    /// The two group profiles differ in exactly one thing, which is the point
    /// of there being two of them.
    ///
    /// Looked up by identifier rather than named directly: an assertion about
    /// two `const`s is one the compiler can fold away, and clippy is right that
    /// a test which cannot fail is not a test. Going through `profile` is the
    /// path a front end takes anyway.
    #[test]
    fn the_two_group_profiles_differ_only_in_the_voices() {
        let voices = profile("group-voices").expect("a built-in profile");
        let one_voice = profile("group-one-voice").expect("a built-in profile");
        let single = profile("individual").expect("a built-in profile");

        assert!(voices.group && one_voice.group);
        assert!(!single.group);
        assert_eq!(voices.voices, VoiceMode::Distinct);
        assert_eq!(one_voice.voices, VoiceMode::Uniform);
        assert_eq!(voices.intensity, one_voice.intensity);
        assert_eq!(voices.neutralise_accent, one_voice.neutralise_accent);
        assert_eq!(voices.reseed_secs, one_voice.reseed_secs);
    }

    /// Every profile seals what it writes and strips metadata. A preset that
    /// quietly turned either off would be a preset that loses somebody data
    /// they thought was protected.
    #[test]
    fn no_profile_turns_off_a_protection() {
        for one in BUILT_IN.iter() {
            assert!(one.encrypt_at_rest, "{}", one.id);
            assert!(one.clean_metadata, "{}", one.id);
            assert!(one.neutralise_accent, "{}", one.id);
            assert!(one.intensity >= 1.0, "{}", one.id);
        }
    }
}