demons 0.1.0

Run a project's development commands side-by-side in one terminal
use std::{
    collections::BTreeMap,
    fs,
    io::{self, BufRead, IsTerminal, Write},
    path::{Path, PathBuf},
};

use anyhow::{Context, Result, bail};

use crate::config::{
    Config, Layout, Leader, Settings, Task, TaskCommand, parse_file, validate_for_path,
};

pub struct InitResult {
    pub path: PathBuf,
    pub start: bool,
}

pub fn run(path: PathBuf) -> Result<InitResult> {
    if !io::stdin().is_terminal() {
        bail!("init requires an interactive terminal");
    }

    let mut input = io::stdin().lock();
    let mut output = io::stdout().lock();
    run_with_io(path, &mut input, &mut output)
}

fn run_with_io(
    path: PathBuf,
    input: &mut impl BufRead,
    output: &mut impl Write,
) -> Result<InitResult> {
    let existing = if path.is_file() {
        Some(parse_file(&path)?)
    } else {
        None
    };

    let config = match existing {
        Some(config) => loop {
            let choice = prompt(
                input,
                output,
                &format!(
                    "A file already exists at {}. [E]dit existing, [F]resh start, [A]bort?",
                    path.display()
                ),
                "E",
            )?;
            match choice.to_ascii_lowercase().as_str() {
                "" | "e" | "edit" => break edit_config(input, output, config)?,
                "f" | "fresh" => break new_config(input, output)?,
                "a" | "abort" => {
                    return Ok(InitResult { path, start: false });
                }
                _ => writeln!(output, "Please choose E, F, or A.")?,
            }
        },
        None => new_config(input, output)?,
    };

    validate_for_path(&config, &path)?;
    let rendered = toml::to_string_pretty(&config).context("failed to render configuration")?;
    writeln!(output, "\n--- {} ---\n{rendered}---", path.display())?;
    if !prompt_yes_no(input, output, "Write this configuration?", true)? {
        return Ok(InitResult { path, start: false });
    }

    write_atomic(&path, rendered.as_bytes())?;
    writeln!(output, "Wrote {}.", path.display())?;
    writeln!(output, "Run 'demons' to start.")?;
    let start = prompt_yes_no(input, output, "Start demons now?", true)?;
    Ok(InitResult { path, start })
}

fn new_config(input: &mut impl BufRead, output: &mut impl Write) -> Result<Config> {
    writeln!(output, "Create a demons configuration.\n")?;
    let settings = prompt_settings(input, output, &Settings::default())?;
    let mut tasks = vec![prompt_task(input, output, None, 1)?];
    while prompt_yes_no(input, output, "Add another task?", true)? {
        let number = tasks.len() + 1;
        tasks.push(prompt_task(input, output, None, number)?);
    }
    Ok(Config { settings, tasks })
}

fn edit_config(
    input: &mut impl BufRead,
    output: &mut impl Write,
    config: Config,
) -> Result<Config> {
    let settings = prompt_settings(input, output, &config.settings)?;
    let mut tasks = Vec::with_capacity(config.tasks.len());
    for (index, task) in config.tasks.iter().enumerate() {
        tasks.push(prompt_task(input, output, Some(task), index + 1)?);
    }
    while prompt_yes_no(input, output, "Add a new task?", true)? {
        let number = tasks.len() + 1;
        tasks.push(prompt_task(input, output, None, number)?);
    }

    if tasks.len() > 1 {
        let names = tasks
            .iter()
            .map(|task| task.name.as_str())
            .collect::<Vec<_>>()
            .join(", ");
        let remove = prompt(
            input,
            output,
            &format!("Remove tasks by name, comma-separated [{names}]"),
            "",
        )?;
        if !remove.trim().is_empty() {
            let removals = remove
                .split(',')
                .map(str::trim)
                .filter(|name| !name.is_empty())
                .collect::<Vec<_>>();
            tasks.retain(|task| !removals.contains(&task.name.as_str()));
        }
    }
    if tasks.is_empty() {
        bail!("a configuration must contain at least one task");
    }
    Ok(Config { settings, tasks })
}

fn prompt_settings(
    input: &mut impl BufRead,
    output: &mut impl Write,
    current: &Settings,
) -> Result<Settings> {
    writeln!(output, "Project settings:")?;
    let layout = prompt(input, output, "Layout", "grid")?;
    if layout != "grid" {
        bail!("layout must be 'grid'");
    }

    let leader_default = match current.leader {
        Leader::AltJ => "alt-j",
        Leader::Tab => "tab",
        Leader::CtrlB => "ctrl-b",
        Leader::CtrlQ => "ctrl-q",
        Leader::CtrlBackslash => "ctrl-\\",
    };
    let leader = match prompt(input, output, "Leader key", leader_default)?.as_str() {
        "alt-j" => Leader::AltJ,
        "tab" => Leader::Tab,
        "ctrl-b" => Leader::CtrlB,
        "ctrl-q" => Leader::CtrlQ,
        "ctrl-\\" => Leader::CtrlBackslash,
        _ => bail!("leader must be one of: alt-j, tab, ctrl-b, ctrl-q, ctrl-\\"),
    };

    Ok(Settings {
        layout: Layout::Grid,
        leader,
        logging: false,
    })
}

fn prompt_task(
    input: &mut impl BufRead,
    output: &mut impl Write,
    current: Option<&Task>,
    number: usize,
) -> Result<Task> {
    writeln!(output, "\nTask {number}:")?;
    let name = required_prompt(
        input,
        output,
        "Name",
        current.map(|task| task.name.as_str()).unwrap_or(""),
    )?;
    let command_default = current
        .map(|task| task.command.display())
        .unwrap_or_default();
    let command = required_prompt(input, output, "Command", &command_default)?;
    let cwd_default = current
        .map(|task| task.cwd.to_string_lossy().into_owned())
        .unwrap_or_else(|| ".".to_owned());
    let cwd = PathBuf::from(prompt(input, output, "Working directory", &cwd_default)?);

    let env_default = current
        .map(|task| format_env(&task.env))
        .unwrap_or_default();
    let env_text = prompt(
        input,
        output,
        "Environment (KEY=value, comma-separated; '-' to clear)",
        &env_default,
    )?;
    let env = if env_text == "-" {
        BTreeMap::new()
    } else {
        parse_env(&env_text)?
    };

    Ok(Task {
        name,
        command: match current.map(|task| &task.command) {
            Some(TaskCommand::Direct(parts)) if command == command_default => {
                TaskCommand::Direct(parts.clone())
            }
            _ => TaskCommand::Shell(command),
        },
        cwd,
        env,
        watch: None,
        run_on_change: None,
        repeat: None,
    })
}

fn prompt(
    input: &mut impl BufRead,
    output: &mut impl Write,
    label: &str,
    default: &str,
) -> Result<String> {
    if default.is_empty() {
        write!(output, "{label}: ")?;
    } else {
        write!(output, "{label} [{default}]: ")?;
    }
    output.flush()?;

    let mut line = String::new();
    if input.read_line(&mut line)? == 0 {
        bail!("input closed");
    }
    let value = line.trim().to_owned();
    Ok(if value.is_empty() {
        default.to_owned()
    } else {
        value
    })
}

fn required_prompt(
    input: &mut impl BufRead,
    output: &mut impl Write,
    label: &str,
    default: &str,
) -> Result<String> {
    loop {
        let value = prompt(input, output, label, default)?;
        if !value.trim().is_empty() {
            return Ok(value);
        }
        writeln!(output, "{label} cannot be empty.")?;
    }
}

pub fn prompt_yes_no(
    input: &mut impl BufRead,
    output: &mut impl Write,
    label: &str,
    default_yes: bool,
) -> Result<bool> {
    loop {
        let suffix = if default_yes { "Y/n" } else { "y/N" };
        let answer = prompt(input, output, &format!("{label} [{suffix}]"), "")?;
        match answer.to_ascii_lowercase().as_str() {
            "" => return Ok(default_yes),
            "y" | "yes" => return Ok(true),
            "n" | "no" => return Ok(false),
            _ => writeln!(output, "Please answer yes or no.")?,
        }
    }
}

fn parse_env(value: &str) -> Result<BTreeMap<String, String>> {
    let mut env = BTreeMap::new();
    if value.trim().is_empty() {
        return Ok(env);
    }
    for pair in value.split(',') {
        let (key, value) = pair
            .trim()
            .split_once('=')
            .with_context(|| format!("environment entry {:?} must use KEY=value", pair.trim()))?;
        let key = key.trim();
        if key.is_empty() || key.contains(char::is_whitespace) {
            bail!("invalid environment variable name {key:?}");
        }
        env.insert(key.to_owned(), value.trim().to_owned());
    }
    Ok(env)
}

fn format_env(env: &BTreeMap<String, String>) -> String {
    env.iter()
        .map(|(key, value)| format!("{key}={value}"))
        .collect::<Vec<_>>()
        .join(", ")
}

fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> {
    let parent = path
        .parent()
        .context("config path has no parent directory")?;
    if !parent.is_dir() {
        bail!("config directory does not exist: {}", parent.display());
    }
    let temporary = parent.join(format!(".{}.tmp", crate::config::CONFIG_FILE));
    fs::write(&temporary, contents)
        .with_context(|| format!("failed to write {}", temporary.display()))?;
    fs::rename(&temporary, path)
        .with_context(|| format!("failed to replace {}", path.display()))?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use std::io::Cursor;

    use tempfile::tempdir;

    use super::*;

    #[test]
    fn parses_environment_entries() {
        let env = parse_env("RUST_LOG=debug, BROWSER=none").unwrap();
        assert_eq!(env["RUST_LOG"], "debug");
        assert_eq!(env["BROWSER"], "none");
    }

    #[test]
    fn rejects_environment_without_equals() {
        assert!(parse_env("RUST_LOG").is_err());
    }

    #[test]
    fn creates_a_config_from_line_input() {
        let temp = tempdir().unwrap();
        let path = temp.path().join(crate::config::CONFIG_FILE);
        let answers = b"\n\nserver\necho ready\n\n\nn\n\nn\n";
        let mut input = Cursor::new(answers);
        let mut output = Vec::new();

        let result = run_with_io(path.clone(), &mut input, &mut output).unwrap();

        assert!(!result.start);
        let config = parse_file(&path).unwrap();
        assert_eq!(config.tasks.len(), 1);
        assert_eq!(config.tasks[0].name, "server");
        assert!(matches!(
            config.tasks[0].command,
            TaskCommand::Shell(ref command) if command == "echo ready"
        ));
    }
}