demons 0.2.0

Run a project's development commands side-by-side in one terminal
mod cli;
mod config;
mod init;
mod layout;
mod runtime;

use std::{
    env,
    io::{self, IsTerminal},
    path::PathBuf,
    process::ExitCode,
};

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

use crate::{
    cli::{Cli, Command},
    config::{CONFIG_FILE, LoadedConfig, explicit_or_discover},
};

fn main() -> ExitCode {
    match try_main() {
        Ok(()) => ExitCode::SUCCESS,
        Err(error) => {
            eprintln!("error: {error:#}");
            ExitCode::FAILURE
        }
    }
}

fn try_main() -> Result<()> {
    let cli = Cli::parse();
    let cwd = env::current_dir().context("failed to determine current directory")?;

    if matches!(cli.command, Some(Command::Init)) {
        let path = init_path(cli.config, &cwd)?;
        if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
            bail!("demons init requires an interactive terminal");
        }
        runtime::configure(LoadedConfig::load_unvalidated_or_default(path)?)?;
        return Ok(());
    }

    let path = match explicit_or_discover(cli.config, &cwd)? {
        Some(path) => path,
        None if io::stdin().is_terminal() && io::stdout().is_terminal() => {
            let should_init = {
                let mut input = io::stdin().lock();
                let mut output = io::stdout().lock();
                let question = format!(
                    "No {CONFIG_FILE} found in {} or its parents. Run 'demons init' here?",
                    cwd.display()
                );
                init::prompt_yes_no(&mut input, &mut output, &question, true)?
            };
            if should_init {
                runtime::configure(LoadedConfig::load_unvalidated_or_default(
                    cwd.join(CONFIG_FILE),
                )?)?;
                return Ok(());
            } else {
                return Ok(());
            }
        }
        None => {
            bail!("no {CONFIG_FILE} found in {} or its parents", cwd.display())
        }
    };

    let loaded = LoadedConfig::load(path)?;
    if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
        bail!("demons requires an interactive terminal");
    }
    runtime::run(loaded)
}

fn init_path(explicit: Option<PathBuf>, cwd: &std::path::Path) -> Result<PathBuf> {
    if let Some(path) = explicit {
        return Ok(if path.is_absolute() {
            path
        } else {
            cwd.join(path)
        });
    }

    Ok(explicit_or_discover(None, cwd)?.unwrap_or_else(|| cwd.join(CONFIG_FILE)))
}

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

    use tempfile::tempdir;

    use super::*;

    #[test]
    fn init_path_uses_nearest_existing_config_without_explicit_path() {
        let temp = tempdir().unwrap();
        let root = temp.path();
        let nested = root.join("app/src");
        fs::create_dir_all(&nested).unwrap();
        let config = root.join(CONFIG_FILE);
        fs::write(&config, "").unwrap();

        assert_eq!(init_path(None, &nested).unwrap(), config);
    }

    #[test]
    fn init_path_creates_in_current_directory_when_no_config_exists() {
        let temp = tempdir().unwrap();

        assert_eq!(
            init_path(None, temp.path()).unwrap(),
            temp.path().join(CONFIG_FILE)
        );
    }
}