feat(crustex): initial commit
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
*.pdf
|
||||
*.tex
|
||||
config.toml
|
||||
.idea/
|
||||
17
Cargo.toml
Normal file
17
Cargo.toml
Normal file
@@ -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"
|
||||
12
readme.md
Normal file
12
readme.md
Normal file
@@ -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
|
||||
142
src/compile/mod.rs
Normal file
142
src/compile/mod.rs
Normal file
@@ -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<TeXFile, Vec<TeXFile>>,
|
||||
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<Vec<String>> = 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<TeXFile, Vec<TeXFile>>, 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<String> {
|
||||
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<String> {
|
||||
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
|
||||
}
|
||||
64
src/config/mod.rs
Normal file
64
src/config/mod.rs
Normal file
@@ -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<String>,
|
||||
pub build_dir: Option<String>,
|
||||
pub results_dir: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Compile {
|
||||
pub latex_compiler: Option<String>,
|
||||
pub bibtex_compiler: Option<String>,
|
||||
pub compiler_flags: Option<Vec<String>>,
|
||||
pub stages: Option<Vec<Stages>>
|
||||
}
|
||||
|
||||
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<Compile>,
|
||||
pub extra: Option<Extra>
|
||||
}
|
||||
|
||||
pub fn load_config<P: AsRef<Path>>(path: P) -> Result<Template, Box<dyn std::error::Error>> {
|
||||
let config_content = fs::read_to_string(path)?;
|
||||
let config: Template = toml::from_str(&config_content)?;
|
||||
Ok(config)
|
||||
}
|
||||
256
src/graph/mod.rs
Normal file
256
src/graph/mod.rs
Normal file
@@ -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<Chars<'a>>,
|
||||
}
|
||||
|
||||
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<Self::Item> {
|
||||
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<I>(tokens: &mut I) -> Option<String>
|
||||
where
|
||||
I: Iterator<Item = Token>
|
||||
{
|
||||
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<I>(tokens: &mut I, target: &Token)
|
||||
where
|
||||
I: Iterator<Item= Token>
|
||||
{
|
||||
for t in tokens {
|
||||
if &t == target { return; }
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_arguments<I>(tokens: &mut I) -> Option<String>
|
||||
where
|
||||
I: Iterator<Item = Token>
|
||||
{
|
||||
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<Include> {
|
||||
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<TeXFile, Vec<TeXFile>> {
|
||||
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<TeXFile, Vec<TeXFile>> = 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
|
||||
}
|
||||
484
src/main.rs
Normal file
484
src/main.rs
Normal file
@@ -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<PathBuf>,
|
||||
|
||||
#[arg(long, short = 'u')]
|
||||
template_url: Option<String>,
|
||||
|
||||
#[arg(long, short = 't')]
|
||||
template_name: Option<String>,
|
||||
},
|
||||
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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
100
src/utils/mod.rs
Normal file
100
src/utils/mod.rs
Normal file
@@ -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<dyn std::error::Error>> {
|
||||
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<bool, Box<dyn std::error::Error>> {
|
||||
let computed_hash = hash_file(path)?;
|
||||
Ok(&computed_hash == expected_hash)
|
||||
}
|
||||
|
||||
pub fn verify_against_binlock(path: &PathBuf, lock_path: &PathBuf) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
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<TeXFile, Vec<TeXFile>>, 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<PathBuf> {
|
||||
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<String> {
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user