saja 0.1.0

Zero-configuration C build system
/*
 * Saja is a zero-configuration build system for C.
 *
 * Copyright (C) 2026  Madeleine Choi
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

use std::{collections::HashMap, env, fs, path::PathBuf, process};

use anyhow::anyhow;
use clang::Clang;
use clap::Parser;
use inquire::{Select, Text};
use log::Level;

use crate::{
    cli::{Arguments, Command},
    config::Config,
};

mod build;
mod cli;
mod config;
mod exports;
mod header;

struct Saja {
    pub clang: Clang,
    pub args: Arguments,
    pub profiles: HashMap<String, Vec<String>>,
    pub config: Config,
}

impl Saja {
    fn default_profiles() -> HashMap<String, Vec<String>> {
        fn profile(common: &[&str], extra: &[&str]) -> Vec<String> {
            common
                .iter()
                .chain(extra.iter())
                .map(ToString::to_string)
                .collect()
        }

        let common = vec![
            "-Wall",
            "-Wextra",
            "-Werror",
            "-Wno-unknown-pragmas",
            r#"-Dpublic=__attribute__((annotate("public")))"#,
        ];

        let cflags = env::var("CFLAGS").unwrap_or_default();

        let common = [
            common.as_slice(),
            cflags.split(' ').collect::<Vec<_>>().as_slice(),
        ]
        .concat();

        HashMap::from([
            ("debug".into(), profile(&common, &["-g", "-O0", "-DDEBUG"])),
            (
                "release".into(),
                profile(&common, &["-O3", "-march=native"]),
            ),
            ("syntax".into(), profile(&common, &["-fsyntax-only"])),
        ])
    }

    fn new() -> anyhow::Result<Self> {
        let args = Arguments::parse();
        let command = args.command;

        let config = match Config::load(&env::current_dir()?) {
            Ok(config) => config,
            Err(_) => {
                if command == Command::Init {
                    Self::init()?;
                    process::exit(0);
                } else {
                    anyhow::bail!(
                        "could not find saja.toml in the current directory, did you initialise a project?"
                    );
                }
            }
        };

        Ok(Self {
            clang: Clang::new().map_err(|e| anyhow!(e))?,
            args: Arguments::parse(),
            profiles: Self::default_profiles(),
            config,
        })
    }

    fn init() -> anyhow::Result<()> {
        let username = process::Command::new("git")
            .args(["config", "--global", "user.name"])
            .output()
            .ok()
            .and_then(|o| String::from_utf8(o.stdout).ok())
            .map(|s| s.trim().to_string());

        let name = Text::new("project name").prompt()?;

        let path = PathBuf::from(&name);

        let author = Text::new("author")
            .with_default(username.as_deref().unwrap_or("John Doe"))
            .prompt()?;

        let standard = Select::new(
            "C standard version",
            vec![
                "c89", "c90", "c99", "c11", "c17", "c23", "gnu89", "gnu90", "gnu99", "gnu11",
                "gnu17", "gnu23",
            ],
        )
        .prompt()?
        .to_string();

        let version = Text::new("version").with_default("0.1.0").prompt()?;

        let config = Config {
            name: name.clone(),
            version,
            standard,
            author,
            license: "NOASSERTION".into(),
        };

        let src = path.join("src");

        fs::create_dir_all(&src)?;
        config.store(&path)?;

        fs::write(
            src.join("main.c"),
            r#"#include <stdio.h>

int main() {
    printf("Hello, world!\n");
    return 0;
}"#,
        )?;

        println!(
            "welcome to saja!
next: `cd {name}`, `saja build`, and `.saja/{name}`"
        );

        Ok(())
    }
}

fn main() -> anyhow::Result<()> {
    let saja = Saja::new()?;

    let level = if saja.args.verbose {
        Level::Debug
    } else {
        Level::Info
    };

    clang_log::init(level, env!("CARGO_PKG_NAME"));

    match &saja.args.command {
        Command::Build {
            profile,
            release: _,
            no_compile_commands,
        } => saja.build(&env::current_dir()?, profile, no_compile_commands)?,

        // dispatch for init is in Saja::new because I don't want to keep config in an Option
        // FIXME: gross
        Command::Init => unreachable!(),
    }

    Ok(())
}