From 7a73fe9752b17793a66c9422621100997cbc4eac Mon Sep 17 00:00:00 2001 From: Emily Boudreaux Date: Wed, 14 Jan 2026 15:13:51 -0500 Subject: [PATCH] feat(crustex): initial commit --- .gitignore | 6 + Cargo.toml | 17 ++ readme.md | 12 ++ src/compile/mod.rs | 142 +++++++++++++ src/config/mod.rs | 64 ++++++ src/graph/mod.rs | 256 ++++++++++++++++++++++++ src/main.rs | 484 +++++++++++++++++++++++++++++++++++++++++++++ src/utils/mod.rs | 100 ++++++++++ 8 files changed, 1081 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 readme.md create mode 100644 src/compile/mod.rs create mode 100644 src/config/mod.rs create mode 100644 src/graph/mod.rs create mode 100644 src/main.rs create mode 100644 src/utils/mod.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9fc3098 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/target +Cargo.lock +*.pdf +*.tex +config.toml +.idea/ \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..b0b9601 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "crustex" +version = "0.1.0" +edition = "2024" +publish = ["gitea"] + +[dependencies] +toml = "0.9.8" +serde = { version = "1.0", features = ["derive"] } +which = "8.0.0" +clap = { version = "4.5.54", features = ["derive"] } +sha2 = "0.10.9" +hex-literal = "1.1.0" +git2 = "0.20.3" +dirs = "6.0.0" +chrono = "0.4.42" +ssh2-config = "0.6.5" \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..8bddf84 --- /dev/null +++ b/readme.md @@ -0,0 +1,12 @@ +# crustex +A simple, rust-based, latex meta-build system. crustex is in very early development. + +## Commands +- init +- setup +- compile +- clean +- version +- publish +- describe +- clear \ No newline at end of file diff --git a/src/compile/mod.rs b/src/compile/mod.rs new file mode 100644 index 0000000..98caa44 --- /dev/null +++ b/src/compile/mod.rs @@ -0,0 +1,142 @@ +use crate::graph::TeXFile; +use crate::config::Template; +use crate::config::Stages; + +use which::which; +use std::collections::HashMap; +use std::path::PathBuf; +use std::process::Command; + +fn check_for_compiler(compiler: &String) -> bool { + which(compiler).is_ok() +} + +pub fn compile_dependency_graph( + dep_graph: &HashMap>, + template: &Template +) { + let latex_compiler = template.compile.as_ref().and_then(|c| c.latex_compiler.as_ref()).unwrap(); + let bibtex_compiler = template.compile.as_ref().and_then(|c| c.bibtex_compiler.as_ref()).unwrap(); + + let stages = template.compile.as_ref().and_then(|c| c.stages.as_ref()).unwrap(); + + if stages.contains(&Stages::Latex) { + if !check_for_compiler(latex_compiler) { + eprintln!("LaTeX compiler '{}' not found in PATH.", latex_compiler); + std::process::exit(1); + } + } + + if stages.contains(&Stages::Bibtex) { + if !check_for_compiler(bibtex_compiler) { + eprintln!("BibTeX compiler '{}' not found in PATH.", bibtex_compiler); + std::process::exit(1); + } + } + + + + + if needs_recompile(dep_graph, template) { + let stage_commands: Vec> = stages.iter().map(|stage| { + match stage { + Stages::Latex => format_latex_command(template), + Stages::Bibtex => format_bibtex_command(template), + } + }).collect(); + if cfg!(target_os = "windows") { + panic!("Windows is not supported yet."); + } else { + for command_args in stage_commands { + let mut cmd = Command::new(&command_args[0]); + if command_args.len() > 1 { + cmd.args(&command_args[1..]); + } + let output = cmd.output().expect("Failed to execute command"); + + if !output.status.success() { + eprintln!("Command '{:?}' failed with status: {}", command_args, output.status); + eprintln!("Stdout: {}", String::from_utf8_lossy(&output.stdout)); + eprintln!("Stderr: {}", String::from_utf8_lossy(&output.stderr)); + std::process::exit(1); + } + } + }; + } +} + +fn needs_recompile(dep_graph: &HashMap>, template: &Template) -> bool { + let output_pdf = format!("{:?}.pdf", template.config.job_name); + + let output_exists = std::path::Path::new(&output_pdf).exists(); + if !output_exists { + return true; + } + + let last_modified_output = std::fs::metadata(&output_pdf) + .and_then(|meta| meta.modified()) + .ok(); + + let mut needs_recompile = false; + for (node, edges) in dep_graph { + let last_modified_tex = std::fs::metadata(&node.filename) + .and_then(|meta| meta.modified()) + .expect("Failed to get last-modified time for TeX file"); + + if let Some(last_modified_output) = last_modified_output { + if last_modified_tex > last_modified_output { + needs_recompile = true; + break; + } + } + + for sub_node in edges { + let last_modified_sub = std::fs::metadata(&sub_node.filename) + .and_then(|meta| meta.modified()) + .expect("Failed to get last-modified time for sub TeX file"); + + if let Some(last_modified_output) = last_modified_output { + if last_modified_sub > last_modified_output { + needs_recompile = true; + break; + } + } + } + } + needs_recompile +} + +fn format_latex_command(template: &Template) -> Vec { + let mut command = Vec::new(); + let latex_compiler = template.compile.as_ref().and_then(|c| c.latex_compiler.as_ref()).unwrap(); + command.push(latex_compiler.clone()); + + let compiler_flags = &template.compile.as_ref().and_then(|c| c.compiler_flags.clone()); + + if let Some(flags) = compiler_flags { + for flag in flags { + command.push(flag.clone()); + } + } + + if let Some(job_name) = &template.config.job_name { + command.push("-jobname".to_string()); + command.push(job_name.clone()); + } + + let main_file = &template.config.main_file; + command.push(main_file.clone()); + command +} + +fn format_bibtex_command(template: &Template) -> Vec { + let mut command = Vec::new(); + let bibtex_compiler = template.compile.as_ref().and_then(|c| c.bibtex_compiler.as_ref()).unwrap(); + command.push(bibtex_compiler.clone()); + + + if let Some(job_name) = &template.config.job_name { + command.push(job_name.clone()); + } + command +} \ No newline at end of file diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..429615b --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,64 @@ +use serde::Deserialize; +use std::fs; +use std::path::Path; + +#[derive(Debug, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum Mode { + Git, + SSH, + Local +} + +#[derive(Debug, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum Stages { + Latex, + Bibtex +} + +#[derive(Debug, Deserialize)] +pub struct Config { + pub main_file: String, + pub job_name: Option, + pub build_dir: Option, + pub results_dir: Option, +} + +#[derive(Debug, Deserialize)] +pub struct Compile { + pub latex_compiler: Option, + pub bibtex_compiler: Option, + pub compiler_flags: Option>, + pub stages: Option> +} + +impl Default for Compile { + fn default() -> Self { + Compile { + latex_compiler: Some("pdflatex".to_string()), + bibtex_compiler: Some("bibtex".to_string()), + compiler_flags: Some(Vec::new()), + stages: Some(vec![Stages::Latex, Stages::Bibtex, Stages::Latex, Stages::Latex]) + } + } +} + +#[derive(Debug, Deserialize)] +pub struct Extra { + pub method: Mode, + pub uri: String +} + +#[derive(Debug, Deserialize)] +pub struct Template { + pub config: Config, + pub compile: Option, + pub extra: Option +} + +pub fn load_config>(path: P) -> Result> { + let config_content = fs::read_to_string(path)?; + let config: Template = toml::from_str(&config_content)?; + Ok(config) +} \ No newline at end of file diff --git a/src/graph/mod.rs b/src/graph/mod.rs new file mode 100644 index 0000000..73dd5b2 --- /dev/null +++ b/src/graph/mod.rs @@ -0,0 +1,256 @@ +use std::alloc::System; +use std::iter::Peekable; +use std::str::Chars; +use std::path::{Path,PathBuf}; + +use std::collections::HashMap; +use std::time::SystemTime; + +#[derive(Debug, PartialEq, Clone)] +pub enum Token { + Command(String), + LBrace, + RBrace, + LBracket, + RBracket, + Text(String), + Comment +} + +#[derive(Debug)] +pub struct Include{ + pub command: String, + pub filename: PathBuf, + pub exists: bool, +} + +#[derive(Eq, Hash, PartialEq, Debug, Clone)] +pub struct TeXFile { + pub filename: PathBuf, + pub last_modified: SystemTime, +} + + +pub struct LatexLexer<'a> { + chars: Peekable>, +} + +impl<'a> LatexLexer<'a> { + pub fn new(input: &'a str) -> Self { + Self { + chars: input.chars().peekable(), + } + } + + fn read_command(&mut self) -> String { + let mut name = String::new(); + if let Some(&c) = self.chars.peek() { + if !c.is_alphabetic() { + self.chars.next(); + name.push(c); + return name; + } + } + + while let Some(&c) = self.chars.peek() { + if c.is_alphabetic() { + name.push(c); + self.chars.next(); + } else { + break; + } + } + name + } + + fn read_text(&mut self) -> String { + let mut text = String::new(); + while let Some(&c) = self.chars.peek() { + match c { + '\\' | '{' | '}' | '[' | ']' | '%' => break, + _ => { + text.push(c); + self.chars.next(); + } + } + } + text + } +} + +impl<'a> Iterator for LatexLexer<'a> { + type Item = Token; + fn next(&mut self) -> Option { + let c = self.chars.next()?; + + match c { + '\\' => Some(Token::Command(self.read_command())), + '{' => Some(Token::LBrace), + '}' => Some(Token::RBrace), + '[' => Some(Token::LBracket), + ']' => Some(Token::RBracket), + '%' => { + while let Some(&next_char) = self.chars.peek() { + if next_char == '\n' { break; } + self.chars.next(); + } + Some(Token::Comment) + } + _ => { + let mut text = String::from(c); + text.push_str(&self.read_text()); + Some(Token::Text(text)) + } + } + } +} + +fn is_include_command(cmd: &str) -> bool { + matches!(cmd, "input" | "include" | "subfile" | "includegraphics" | "bibliography" | "addbibresource" | "documentclass") +} + +fn collect_text_until_brace(tokens: &mut I) -> Option +where + I: Iterator +{ + let mut result = String::new(); + let mut depth = 1; + + while let Some(token) = tokens.next() { + match token { + Token::RBrace => { + depth -= 1; + if depth == 0 { + return Some(result.trim().to_string()); + } + result.push('}') + } + Token::LBrace => { + depth += 1; + result.push('{'); + } + Token::Text(t) => { + result.push_str(&t); + } + Token::Command(c) => { + result.push('\\'); + result.push_str(&c); + } + _ => {} + } + } + None +} + +fn skip_until_match(tokens: &mut I, target: &Token) +where + I: Iterator +{ + for t in tokens { + if &t == target { return; } + } +} + +fn parse_arguments(tokens: &mut I) -> Option +where + I: Iterator +{ + while let Some(token) = tokens.next() { + match token { + Token::LBracket => { + skip_until_match(tokens, &Token::RBracket); + } + Token::LBrace => { + return collect_text_until_brace(tokens); + } + Token::Text(t) if t.trim().is_empty() => { + continue; + } + _ => { + return None; + } + } + } + None + +} + +pub fn extract_includes(input: &str) -> Vec { + let lexer = LatexLexer::new(input); + let mut refs = Vec::new(); + + let mut tokens = lexer.filter(|t| !matches!(t,Token::Comment)); + + while let Some(token) = tokens.next() { + if let Token::Command(cmd_name) = token { + if is_include_command(&cmd_name) { + if let Some(filename) = parse_arguments(&mut tokens) { + let path = PathBuf::from(&filename); + let exists = path.exists(); + refs.push(Include { + command: cmd_name, + filename: path, + exists, + }); + } + } + } + } + refs +} + +pub fn build_dependency_map(filepath: &PathBuf) -> HashMap> { + let root_directory = filepath.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| PathBuf::from(".")); + let input = std::fs::read_to_string(filepath).expect("Failed to read LaTeX main file"); + + let main_tex_file = TeXFile { + filename: filepath.clone(), + last_modified: std::fs::metadata(filepath).expect("Failed to get metadata for main file").modified().expect("Failed to get last-modified time for main file") + }; + + let includes = extract_includes(&input); + let mut dep_map: HashMap> = HashMap::new(); + + dep_map.insert(main_tex_file.clone(), Vec::new()); + + for include in includes { + if !include.exists && include.command == "documentclass" { + continue; + } + let include_path = &include.filename; + if !include_path.exists() { + eprintln!("Error: Included file {:?} does not exist, halting...", include_path); + std::process::exit(1); + } + + match include.command.as_str() { + "input" | "include" | "subfile" | "import" | "subimport" | "documentclass" => { + let sub_dep_map = build_dependency_map(&include_path); + + let meta = std::fs::metadata(&include_path).expect("Failed to get metadata for file: {include.filename:?}"); + let modified_time = meta.modified().expect("Failed to get last-modified time for file: {include.filename:?}"); + let sub_file = TeXFile { + filename: include_path.clone(), + last_modified: modified_time + }; + + // Merge sub_dep_map into dep_map + for (key, value) in sub_dep_map { + dep_map.entry(key).or_insert_with(Vec::new).extend(value); + } + + dep_map.entry(main_tex_file.clone()).and_modify(|v| v.push(sub_file)); + } + _ => { + let meta = std::fs::metadata(&include_path).expect("Failed to get metadata for file: {include.filename:?}"); + let modified_time = meta.modified().expect("Failed to get last-modified time for file: {include.filename:?}"); + let sub_file = TeXFile { + filename: include_path.clone(), + last_modified: modified_time + }; + dep_map.entry(main_tex_file.clone()).and_modify(|v| v.push(sub_file)); + } + } + } + dep_map +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..6713bae --- /dev/null +++ b/src/main.rs @@ -0,0 +1,484 @@ +mod config; +mod utils; +mod graph; +mod compile; + +use std::path::PathBuf; +use crate::config::load_config; +use crate::graph::build_dependency_map; +use crate::compile::compile_dependency_graph; +use crate::utils::{hash_file, verify_against_binlock, clone_dep_graph_structure, get_host_from_url, get_ssh_key_for_host}; + +use git2::{Repository, Cred, RemoteCallbacks, Error}; + +use clap::Parser; +use clap::Subcommand; + +#[derive(Parser)] +#[command(name = "crustex LaTeX Build Tool", version = "1.0", author = "Emily M. Boudreaux", about = "Simplified LaTeX builds.")] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + Setup { + config_file: PathBuf, + + #[arg(long, short)] + overwrite: bool + }, + Compile { + build_dir: PathBuf, + }, + Reconfigure { + config_file: PathBuf, + }, + Clear { + build_dir: PathBuf, + }, + Describe { + build_dir: PathBuf, + }, + Publish { + build_dir: PathBuf, + }, + Init { + project_name: String, + + #[arg(long, short = 'd')] + project_dir: Option, + + #[arg(long, short = 'u')] + template_url: Option, + + #[arg(long, short = 't')] + template_name: Option, + }, + Template { + #[command(subcommand)] + command: TemplateCommands + }, + Version {} +} + +#[derive(Subcommand)] +enum TemplateCommands { + Register { + template_name: String, + template_url: String, + }, + List, + Inspect { + template_name: String, + }, + Remove { + template_name: String, + }, + GetUrl { + template_name: String, + }, + SetUrl { + template_name: String, + template_url: String, + }, +} + +fn main() { + let cli = Cli::parse(); + let mut callbacks = RemoteCallbacks::new(); + callbacks.credentials(|_url, username_from_url, _allowed_types| { + let username = username_from_url.unwrap_or("git"); + let host = get_host_from_url(_url).unwrap_or_default(); + if let Some(ssh_key_path) = get_ssh_key_for_host(&host) { + println!("Using credentials from url: {} (key path: {})", host, ssh_key_path.display()); + return Cred::ssh_key( + username, + None, + &ssh_key_path, + None + ); + } + + Cred::default() + }); + + let mut fo = git2::FetchOptions::new(); + fo.remote_callbacks(callbacks); + + + match &cli.command { + Commands::Setup { config_file, overwrite } => { + let template = match load_config(config_file) { + Ok(cfg) => cfg, + Err(e) => { + eprintln!("Failed to load config: {}", e); + return; + } + }; + + let mut build_path = PathBuf::new(); + if template.config.build_dir.is_some() { + let build_dir = template.config.build_dir.unwrap(); + build_path = PathBuf::from(build_dir); + } else { + build_path = PathBuf::from("build"); + } + + if build_path.exists() && overwrite != &true { + eprintln!("Build directory {:?} already exists. Use --overwrite to overwrite.", build_path); + std::process::exit(1); + } else if build_path.exists() && overwrite == &true { + std::fs::remove_dir_all(&build_path).unwrap(); + std::fs::create_dir_all(&build_path).unwrap(); + } else { + std::fs::create_dir_all(&build_path).unwrap(); + } + + let dest_config_path = build_path.join("crustex.toml"); + std::fs::copy(config_file, dest_config_path).unwrap(); + + let file_hash = hash_file(config_file).unwrap(); + + let hash_path = build_path.join(".crustex.lock"); + std::fs::write(hash_path, file_hash).unwrap(); + } + Commands::Compile { build_dir } => { + let verified = verify_against_binlock( + &build_dir.join("crustex.toml"), + &build_dir.join(".crustex.lock") + ).and_then(|v| Ok(v)).unwrap(); + if !verified { + eprintln!("Configuration file has been modified since setup. Please reconfigure."); + std::process::exit(1); + } + + let template = match load_config(build_dir.join("crustex.toml")) { + Ok(cfg) => cfg, + Err(e) => { + eprintln!("Failed to load config: {}", e); + return; + } + }; + + let dep_graph = build_dependency_map(&PathBuf::from(&template.config.main_file)); + clone_dep_graph_structure(&dep_graph, &build_dir); + + // Change the current working directory to the build directory + std::env::set_current_dir(&build_dir).unwrap(); + + compile_dependency_graph(&dep_graph, &template); + } + Commands::Reconfigure { config_file } => { + let template = match load_config(config_file) { + Ok(cfg) => cfg, + Err(e) => { + eprintln!("Failed to load config: {}", e); + return; + } + }; + + let mut build_path = PathBuf::new(); + if template.config.build_dir.is_some() { + let build_dir = template.config.build_dir.unwrap(); + build_path = PathBuf::from(build_dir); + } else { + build_path = PathBuf::from("build"); + } + + if !build_path.exists() { + eprintln!("Build directory {:?} does not exist. Please run setup first.", build_path); + std::process::exit(1); + } + + let dest_config_path = build_path.join("crustex.toml"); + std::fs::copy(config_file, dest_config_path).unwrap(); + let file_hash = hash_file(config_file).unwrap(); + let hash_path = build_path.join(".crustex.lock"); + std::fs::write(hash_path, file_hash).unwrap(); + } + Commands::Clear { build_dir } => { + let entries = std::fs::read_dir(build_dir).unwrap(); + for entry in entries { + let entry = entry.unwrap(); + let path = entry.path(); + if path.file_name().unwrap() != "crustex.toml" && path.file_name().unwrap() != ".crustex.lock" { + if path.is_dir() { + std::fs::remove_dir_all(path).unwrap(); + } else { + std::fs::remove_file(path).unwrap(); + } + } + } + } + Commands::Describe { build_dir } => { + let template = match load_config(build_dir.join("crustex.toml")) { + Ok(cfg) => cfg, + Err(e) => { + eprintln!("Failed to load config: {}", e); + return; + } + }; + println!("{:#?}", template); + } + Commands::Publish { build_dir } => { + let template = match load_config(build_dir.join("crustex.toml")) { + Ok(cfg) => cfg, + Err(e) => { + eprintln!("Failed to load config: {}", e); + return; + } + }; + + let main_file_path = PathBuf::from(&template.config.main_file); + let main_file_stem = main_file_path.file_stem().and_then(|s| s.to_str()).unwrap(); + + let job_name = &template.config.job_name.unwrap_or(main_file_stem.to_string()); + let output_pdf = format!("{}.pdf", job_name); + + let results_dir = if template.config.results_dir.is_some() { + PathBuf::from(template.config.results_dir.unwrap()) + } else { + PathBuf::from(".") + }; + + if !results_dir.exists() { + std::fs::create_dir_all(&results_dir).unwrap(); + } + + let dest_path = results_dir.join(&output_pdf); + let src_path = build_dir.join(&output_pdf); + std::fs::copy(&src_path, &dest_path).unwrap(); + } + Commands::Init { project_name, project_dir, template_url, template_name } => { + let dir_path = if let Some(dir) = project_dir { + dir.clone() + } else { + PathBuf::from(".").join(project_name) + }; + + if dir_path.exists() { + eprintln!("Directory {:?} already exists. Aborting.", dir_path); + std::process::exit(1); + } + + std::fs::create_dir_all(&dir_path).unwrap(); + + if template_name.is_some() && template_url.is_some() { + eprintln!("Please provide either a template name or a template URL, not both."); + std::process::exit(1); + } + + if (template_name.is_some() || template_url.is_some()) { + let mut builder = git2::build::RepoBuilder::new(); + builder.fetch_options(fo); + + if let Some(template_name) = template_name { + let home_dir = dirs::home_dir().unwrap(); + let config_dir = home_dir.join(".config").join("crustex"); + if !config_dir.exists() { + eprintln!("Configuration directory / crustex.toml {:?} does not exist. Have you registered any templates? Aborting.", config_dir); + std::process::exit(1); + } + let templates_config_path = config_dir.join("templates.toml"); + let templates_content = std::fs::read_to_string(&templates_config_path).unwrap(); + let templates: toml::Value = toml::from_str(&templates_content).unwrap_or(toml::Value::Table(toml::map::Map::new())); + if let toml::Value::Table(table) = templates { + if let Some(details) = table.get(template_name) { + match builder.clone( + details.get("url").and_then(|v| v.as_str()).unwrap_or(""), + &dir_path + ) { + Ok(_) => { + println!("Cloned template '{}' into {:?}", template_name, dir_path); + } + Err(E) => { + eprintln!("Failed to clone template: {}", E); + std::process::exit(1); + } + }; + } else { + println!("Template '{}' not found.", template_name); + } + } else { + println!("No templates registered."); + } + + } + + if let Some(template_url) = template_url { + match builder.clone(template_url, &dir_path) { + Ok(_) => { + println!("Cloned template from {} into {:?}", template_url, dir_path); + } + Err(E) => { + eprintln!("Failed to clone template: {}", E); + std::process::exit(1); + } + } + } + let git_dir = dir_path.join(".git"); + if git_dir.exists() { + std::fs::remove_dir_all(git_dir).unwrap(); + } + } + + match Repository::init(&dir_path) { + Ok(_) => { + println!("Initialized Git repository in {:?}", dir_path); + } + Err(e) => { + eprintln!("Failed to initialize Git repository: {}", e); + std::process::exit(1); + } + } + let main_tex_filename = format!("{}.tex", project_name); + + let default_config = format!(r#"[config] +main_file = "{}" +job_name = "{}" +build_dir = "build" +results_dir = "." + +[compile] +latex_compiler = "pdflatex" +bibtex_compiler = "bibtex" +compiler_flags = ["-interaction=nonstopmode", "-halt-on-error"] +stages = ["latex", "bibtex", "latex", "latex"] +"#, main_tex_filename, project_name); + + let default_main_tex = r#"% Crustex LaTeX Project Main File +\documentclass{article} +\begin{document} +Hello, Crustex! +\end{document} +"#; + + std::fs::write(dir_path.join("crustex.toml"), default_config).unwrap(); + std::fs::write(dir_path.join(&main_tex_filename), default_main_tex).unwrap(); + + println!("Initialized new Crustex project in {:?}", dir_path); + + let repo = Repository::open(&dir_path).unwrap(); + let mut index = repo.index().unwrap(); + index.add_path(std::path::Path::new("crustex.toml")).unwrap(); + index.add_path(std::path::Path::new(&main_tex_filename)).unwrap(); + index.write().unwrap(); + let oid = index.write_tree().unwrap(); + let signature = repo.signature().unwrap(); + let tree = repo.find_tree(oid).unwrap(); + repo.commit(Some("HEAD"), &signature, &signature, "Initial commit", &tree, &[]).unwrap(); + } + Commands::Template { command } => { + let home_dir = dirs::home_dir().unwrap(); + let config_dir = home_dir.join(".config").join("crustex"); + if !config_dir.exists() { + std::fs::create_dir_all(&config_dir).unwrap(); + } + + // check if templates.toml exists, if not create it + let templates_config_path = config_dir.join("templates.toml"); + if !templates_config_path.exists() { + std::fs::write(&templates_config_path, "").unwrap(); + } + + match command { + TemplateCommands::Register { template_name, template_url } => { + let mut templates_content = std::fs::read_to_string(&templates_config_path).unwrap(); + let new_entry = format!(r#"[{}] +url = "{}" +added = "{}" +"#, template_name, template_url, chrono::Utc::now().to_rfc3339()); + templates_content.push_str(&new_entry); + std::fs::write(&templates_config_path, templates_content).unwrap(); + println!("Registered template '{}' with URL '{}'.", template_name, template_url); + } + TemplateCommands::List => { + let templates_content = std::fs::read_to_string(&templates_config_path).unwrap(); + let templates: toml::Value = toml::from_str(&templates_content).unwrap_or(toml::Value::Table(toml::map::Map::new())); + if let toml::Value::Table(table) = templates { + for (name, details) in table { + if let toml::Value::Table(detail_table) = details { + let url = detail_table.get("url").and_then(|v| v.as_str()).unwrap_or("N/A"); + let added = detail_table.get("added").and_then(|v| v.as_str()).unwrap_or("N/A"); + println!("Template: {}\n URL: {}\n Added: {}\n", name, url, added); + } + } + } else { + println!("No templates registered."); + } + } + TemplateCommands::Inspect { template_name } => { + let templates_content = std::fs::read_to_string(&templates_config_path).unwrap(); + let templates: toml::Value = toml::from_str(&templates_content).unwrap_or(toml::Value::Table(toml::map::Map::new())); + if let toml::Value::Table(table) = templates { + if let Some(details) = table.get(template_name) { + println!("Details for template '{}':\n{:#?}", template_name, details); + } else { + println!("Template '{}' not found.", template_name); + } + } else { + println!("No templates registered."); + } + } + TemplateCommands::Remove { template_name } => { + let templates_content = std::fs::read_to_string(&templates_config_path).unwrap(); + let mut templates: toml::Value = toml::from_str(&templates_content).unwrap_or(toml::Value::Table(toml::map::Map::new())); + if let toml::Value::Table(ref mut table) = templates { + if table.remove(template_name).is_some() { + let updated_content = toml::to_string(&templates).unwrap(); + std::fs::write(&templates_config_path, updated_content).unwrap(); + println!("Removed template '{}'.", template_name); + } else { + println!("Template '{}' not found.", template_name); + } + } else { + println!("No templates registered."); + } + } + TemplateCommands::GetUrl { template_name } => { + let templates_content = std::fs::read_to_string(&templates_config_path).unwrap(); + let templates: toml::Value = toml::from_str(&templates_content).unwrap_or(toml::Value::Table(toml::map::Map::new())); + if let toml::Value::Table(table) = templates { + if let Some(details) = table.get(template_name) { + if let toml::Value::Table(detail_table) = details { + let url = detail_table.get("url").and_then(|v| v.as_str()).unwrap_or("N/A"); + println!("URL for template '{}': {}", template_name, url); + } else { + println!("Template '{}' details are malformed.", template_name); + } + } else { + println!("Template '{}' not found.", template_name); + } + } else { + println!("No templates registered."); + } + } + TemplateCommands::SetUrl { template_name, template_url } => { + let templates_content = std::fs::read_to_string(&templates_config_path).unwrap(); + let mut templates: toml::Value = toml::from_str(&templates_content).unwrap_or(toml::Value::Table(toml::map::Map::new())); + if let toml::Value::Table(ref mut table) = templates { + if let Some(details) = table.get_mut(template_name) { + if let toml::Value::Table(detail_table) = details { + detail_table.insert("url".to_string(), toml::Value::String(template_url.clone())); + let updated_content = toml::to_string(&templates).unwrap(); + std::fs::write(&templates_config_path, updated_content).unwrap(); + println!("Updated URL for template '{}'.", template_name); + } else { + println!("Template '{}' details are malformed.", template_name); + } + } else { + println!("Template '{}' not found.", template_name); + } + } else { + println!("No templates registered."); + } + } + } + } + Commands::Version{} => { + println!("crustex version: {}", env!("CARGO_PKG_VERSION")); + } + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..d464ceb --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,100 @@ +use hex_literal::hex; +use sha2::{Sha256, Digest}; + +use std::path::PathBuf; +use std::collections::HashMap; +use std::fs::File; +use std::io::BufReader; +use ssh2_config::{ParseRule, SshConfig}; +use crate::graph::TeXFile; + +pub fn hash_file(path: &PathBuf) -> Result<[u8; 32], Box> { + let content = std::fs::read_to_string(path)?; + let mut hasher = Sha256::new(); + hasher.update(content.as_bytes()); + let result = hasher.finalize(); + let hash_array: [u8; 32] = result.into(); + Ok(hash_array) +} + +pub fn verify_file_hash(path: &PathBuf, expected_hash: &[u8; 32]) -> Result> { + let computed_hash = hash_file(path)?; + Ok(&computed_hash == expected_hash) +} + +pub fn verify_against_binlock(path: &PathBuf, lock_path: &PathBuf) -> Result> { + let lock_content = std::fs::read(lock_path)?; + if lock_content.len() != 32 { + return Err("Invalid lock file length".into()); + } + + let expected_hash = hash_file(path)?; + + // Compare byte by byte + for i in 0..32 { + if lock_content[i] != expected_hash[i] { + return Ok(false); + } + } + Ok(true) +} + +pub fn clone_dep_graph_structure(dep_graph: &HashMap>, build_dir: &PathBuf) { + for (node, edges) in dep_graph { + println!("Cloning file: {:?}", node.filename); + let parent_path = node.filename.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| PathBuf::from(".")); + let cloned_path = build_dir.join(&parent_path); + if !cloned_path.exists() { + std::fs::create_dir_all(&cloned_path).unwrap(); + } + let new_file_path = build_dir.join(&node.filename); + std::fs::copy(&node.filename, &new_file_path).unwrap(); + for sub_node in edges { + let sub_parent_path = sub_node.filename.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| PathBuf::from(".")); + let sub_cloned_path = build_dir.join(&sub_parent_path); + if !sub_cloned_path.exists() { + std::fs::create_dir_all(&sub_cloned_path).unwrap(); + } + let sub_new_file_path = build_dir.join(&sub_node.filename); + std::fs::copy(&sub_node.filename, &sub_new_file_path).unwrap(); + } + } +} + +pub fn get_ssh_key_for_host(host: &str) -> Option { + let home = dirs::home_dir()?; + + let config_path = home.join(".ssh").join("config"); + if !config_path.exists() { + return None; + } + + let mut reader = BufReader::new(File::open(config_path).ok()?); + let config = SshConfig::default().parse(&mut reader, ParseRule::ALLOW_UNKNOWN_FIELDS).ok()?; + + let params = config.query(host); + params.identity_file?.first().map(PathBuf::from) +} + +pub fn get_host_from_url(url: &str) -> Option { + if url.starts_with("ssh://") { + let without_scheme = &url[6..]; + let parts: Vec<&str> = without_scheme.split('/').collect(); + let host_part = parts[0]; + let host = host_part.split('@').last().unwrap_or(host_part); + return Some(host.to_string()) + } else if url.starts_with("https://") { + let without_scheme = &url[8..]; + let parts: Vec<&str> = without_scheme.split('/').collect(); + let host_part = parts[0]; + let host = host_part.split(':').next().unwrap_or(host_part); + return Some(host.to_string()) + } else if url.contains('@') { + let parts: Vec<&str> = url.split('@').collect(); + let host_part = parts[1]; + let host = host_part.split(':').next().unwrap_or(host_part); + return Some(host.to_string()) + } else { + return None + } +}