Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "texforge"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
rust-version = "1.75"
description = "Self-contained LaTeX to PDF compiler CLI"
Expand Down Expand Up @@ -38,6 +38,10 @@ flate2 = "1.1"
tar = "0.4"
zip = { version = "2", default-features = false, features = ["deflate"] }

# Mermaid diagram rendering
mermaid-rs-renderer = { version = "0.2", default-features = false }
resvg = "0.46"

[dev-dependencies]
tempfile = "3.8"

Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ texforge fmt

# Build to PDF
texforge build

# Remove build artifacts
texforge clean
```

---
Expand All @@ -93,6 +96,7 @@ texforge build
| `texforge new <name> -t <template>` | Create with specific template |
| `texforge init` | Initialize texforge in an existing LaTeX project |
| `texforge build` | Compile to PDF |
| `texforge clean` | Remove build artifacts |
| `texforge fmt` | Format .tex files |
| `texforge fmt --check` | Check formatting without modifying |
| `texforge check` | Lint without compiling |
Expand Down
3 changes: 3 additions & 0 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ pub struct Cli {

#[derive(Subcommand)]
enum Commands {
/// Remove build artifacts
Clean,
/// Initialize a texforge project in the current directory
Init,
/// Create a new project from a template
Expand Down Expand Up @@ -61,6 +63,7 @@ enum TemplateAction {
impl Cli {
pub fn execute(self) -> Result<()> {
match self.command {
Commands::Clean => commands::clean::execute(),
Commands::Init => commands::init::execute(),
Commands::New { name, template } => commands::new::execute(&name, template.as_deref()),
Commands::Build => commands::build::execute(),
Expand Down
13 changes: 11 additions & 2 deletions src/commands/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
use anyhow::Result;

use crate::compiler;
use crate::diagrams;
use crate::domain::project::Project;

/// Compile project to PDF.
Expand All @@ -11,10 +12,18 @@ pub fn execute() -> Result<()> {

println!("Building project: {}", project.config.documento.titulo);

// Ensure build directory exists
std::fs::create_dir_all(project.root.join("build"))?;

compiler::compile(&project.root, &project.config.compilacion.entry)?;
// Pre-process embedded diagrams — works on copies in build/, originals untouched
diagrams::process(&project.root, &project.config.compilacion.entry)?;

// Compile from build/ — all assets are mirrored there, diagrams use relative paths
let build_dir = project.root.join("build");
let entry_filename = std::path::Path::new(&project.config.compilacion.entry)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or(project.config.compilacion.entry.clone());
compiler::compile(&build_dir, &entry_filename)?;

let pdf_name = std::path::Path::new(&project.config.compilacion.entry).with_extension("pdf");
println!("✅ build/{}", pdf_name.display());
Expand Down
20 changes: 20 additions & 0 deletions src/commands/clean.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//! `texforge clean` command implementation.

use anyhow::Result;

use crate::domain::project::Project;

/// Remove the build/ directory.
pub fn execute() -> Result<()> {
let project = Project::load()?;
let build_dir = project.root.join("build");

if !build_dir.exists() {
println!("Nothing to clean.");
return Ok(());
}

std::fs::remove_dir_all(&build_dir)?;
println!("✅ build/ removed");
Ok(())
}
1 change: 1 addition & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

pub mod build;
pub mod check;
pub mod clean;
pub mod fmt;
pub mod init;
pub mod new;
Expand Down
32 changes: 31 additions & 1 deletion src/commands/new.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ bibliografia = "bib/references.bib"
}

/// Validate project name: no empty, no path traversal, no special chars.
fn validate_project_name(name: &str) -> Result<()> {
pub(crate) fn validate_project_name(name: &str) -> Result<()> {
if name.is_empty() {
anyhow::bail!("Project name cannot be empty");
}
Expand Down Expand Up @@ -106,3 +106,33 @@ fn validate_project_name(name: &str) -> Result<()> {

Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn empty_name_is_error() {
assert!(validate_project_name("").is_err());
}

#[test]
fn name_with_spaces_is_error() {
assert!(validate_project_name("my project").is_err());
}

#[test]
fn name_with_dotdot_is_error() {
assert!(validate_project_name("../evil").is_err());
}

#[test]
fn name_with_slash_is_error() {
assert!(validate_project_name("a/b").is_err());
}

#[test]
fn valid_name_is_ok() {
assert!(validate_project_name("mi-tesis").is_ok());
}
}
119 changes: 8 additions & 111 deletions src/compiler/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@ use std::process::Command;
use anyhow::{Context, Result};

/// Compile a LaTeX project to PDF using Tectonic.
/// `root` is the working directory; output PDF goes into `root/` itself.
pub fn compile(root: &Path, entry: &str) -> Result<()> {
let tectonic = find_tectonic()?;
let entry_path = root.join(entry);

let output = Command::new(&tectonic)
.arg(&entry_path)
.arg("--outdir")
.arg(root.join("build"))
.arg(root)
.arg("--keep-logs")
.current_dir(root)
.output()
Expand Down Expand Up @@ -96,7 +97,7 @@ fn parse_errors(raw: &str) -> Vec<CompileError> {
errors
}

/// Find the tectonic binary in PATH or known locations, auto-installing if needed.
/// Find the tectonic binary in PATH or known locations.
fn find_tectonic() -> Result<std::path::PathBuf> {
// Check PATH
if let Ok(output) = Command::new("which").arg("tectonic").output() {
Expand All @@ -107,9 +108,8 @@ fn find_tectonic() -> Result<std::path::PathBuf> {
}

// Check known locations (including texforge-managed install)
let managed = dirs::home_dir().map(|h| h.join(".texforge/bin/tectonic"));
for candidate in [
managed.clone(),
dirs::home_dir().map(|h| h.join(".texforge/bin/tectonic")),
dirs::home_dir().map(|h| h.join(".cargo/bin/tectonic")),
Some("/usr/local/bin/tectonic".into()),
Some("/opt/homebrew/bin/tectonic".into()),
Expand All @@ -122,112 +122,9 @@ fn find_tectonic() -> Result<std::path::PathBuf> {
}
}

// Auto-install tectonic
eprintln!("Tectonic not found. Installing automatically...");
let dest = managed.ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;
install_tectonic(&dest)?;
Ok(dest)
}

/// Download and install tectonic to the given path.
fn install_tectonic(dest: &std::path::Path) -> Result<()> {
let target = current_target()?;
let version = "0.15.0";
let (filename, is_zip) = if target.contains("windows") {
(format!("tectonic-{}-{}.zip", version, target), true)
} else {
(format!("tectonic-{}-{}.tar.gz", version, target), false)
};

let url = format!(
"https://github.com/tectonic-typesetting/tectonic/releases/download/tectonic%40{}/{}",
version, filename
anyhow::bail!(
"Tectonic not found. Install everything with:\n\
\n curl -fsSL https://raw.githubusercontent.com/JheisonMB/texforge/main/install.sh | sh\n\
\nor install tectonic separately: cargo install tectonic"
);

eprintln!("Downloading tectonic {}...", version);

let response = reqwest::blocking::Client::new()
.get(&url)
.header("User-Agent", "texforge")
.send()
.context("Failed to download tectonic")?;

if !response.status().is_success() {
anyhow::bail!(
"Failed to download tectonic: HTTP {}\nURL: {}",
response.status(),
url
);
}

let bytes = response.bytes()?;

if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}

if is_zip {
install_from_zip(&bytes, dest)?;
} else {
install_from_targz(&bytes, dest)?;
}

// Make executable on Unix
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(dest, std::fs::Permissions::from_mode(0o755))?;
}

eprintln!("✅ Tectonic installed to {}", dest.display());
Ok(())
}

fn install_from_targz(bytes: &[u8], dest: &std::path::Path) -> Result<()> {
let decoder = flate2::read::GzDecoder::new(bytes);
let mut archive = tar::Archive::new(decoder);
for entry in archive.entries()? {
let mut entry = entry?;
let path = entry.path()?.to_string_lossy().to_string();
if path.ends_with("tectonic") || path == "tectonic" {
std::io::copy(&mut entry, &mut std::fs::File::create(dest)?)?;
return Ok(());
}
}
anyhow::bail!("tectonic binary not found in archive")
}

fn install_from_zip(bytes: &[u8], dest: &std::path::Path) -> Result<()> {
let cursor = std::io::Cursor::new(bytes);
let mut archive = zip::ZipArchive::new(cursor)?;
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
if file.name().ends_with("tectonic.exe") || file.name() == "tectonic.exe" {
std::io::copy(&mut file, &mut std::fs::File::create(dest)?)?;
return Ok(());
}
}
anyhow::bail!("tectonic.exe not found in archive")
}

/// Detect the current platform target triple for tectonic releases.
fn current_target() -> Result<&'static str> {
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
return Ok("x86_64-unknown-linux-musl");
#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
return Ok("aarch64-unknown-linux-musl");
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
return Ok("x86_64-apple-darwin");
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
return Ok("aarch64-apple-darwin");
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
return Ok("x86_64-pc-windows-msvc");
#[cfg(not(any(
all(target_os = "linux", target_arch = "x86_64"),
all(target_os = "linux", target_arch = "aarch64"),
all(target_os = "macos", target_arch = "x86_64"),
all(target_os = "macos", target_arch = "aarch64"),
all(target_os = "windows", target_arch = "x86_64"),
)))]
anyhow::bail!("Unsupported platform for automatic tectonic installation")
}
Loading
Loading