diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0dcc79c..3b8ef69 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,7 @@ on: - closed paths: - Cargo.toml + workflow_dispatch: permissions: contents: write @@ -18,7 +19,7 @@ env: jobs: create-tag: name: Create Release Tag - if: github.event.pull_request.merged == true + if: github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest outputs: tag: ${{ steps.version.outputs.tag }} @@ -32,7 +33,7 @@ jobs: - name: Get version from Cargo.toml id: version run: | - VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/' | tr -d '\r') + VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/' | tr -d '\r' | xargs) TAG="v${VERSION}" echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "tag=${TAG}" >> $GITHUB_OUTPUT @@ -107,7 +108,7 @@ jobs: if: matrix.archive == 'tar.gz' run: | cd target/${{ matrix.target }}/release - tar czf ../../../texforge-${{ needs.create-tag.outputs.tag }}-${{ matrix.target }}.tar.gz texforge + tar czf "../../../texforge-${{ needs.create-tag.outputs.tag }}-${{ matrix.target }}.tar.gz" texforge cd ../../.. - name: Package (windows) @@ -115,7 +116,7 @@ jobs: shell: pwsh run: | cd target/${{ matrix.target }}/release - Compress-Archive -Path texforge.exe -DestinationPath ../../../texforge-${{ needs.create-tag.outputs.tag }}-${{ matrix.target }}.zip + Compress-Archive -Path texforge.exe -DestinationPath "../../../texforge-${{ needs.create-tag.outputs.tag }}-${{ matrix.target }}.zip" cd ../../.. - name: Upload artifact @@ -154,9 +155,39 @@ jobs: - name: Approval granted run: echo "Release approved for publishing to crates.io" + ensure-owners: + name: Ensure crates.io owners + needs: create-tag + if: needs.create-tag.outputs.created == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.create-tag.outputs.tag }} + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Add crates.io owners (best-effort) + env: + TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + OWNERS: github:univerlab:owners + run: | + set -eu + if [ -z "$TOKEN" ]; then + echo "No cargo token found in CARGO_REGISTRY_TOKEN; skipping owners update" + exit 0 + fi + CRATE_NAME=$(grep '^name = ' Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/' | tr -d '\r') + echo "crate=$CRATE_NAME" + for owner in $(echo "$OWNERS" | tr ',' ' '); do + echo "Adding owner: $owner" + cargo owner --token "$TOKEN" --add "$owner" || echo "cargo owner failed for $owner (continuing)" + done + publish: name: Publish to crates.io - needs: [create-tag, approve] + needs: [create-tag, approve, ensure-owners] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -166,5 +197,24 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable + - name: Check if stable release + id: version_check + run: | + VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/' | xargs) + if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "is_stable=true" >> $GITHUB_OUTPUT + echo "Version $VERSION is stable — will publish" + else + echo "is_stable=false" >> $GITHUB_OUTPUT + echo "Version $VERSION is prerelease/dev — skipping publish" + fi + - name: Cargo publish - run: cargo publish --token ${{ secrets.CARGO_TOKEN }} + if: steps.version_check.outputs.is_stable == 'true' + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + run: cargo publish --allow-dirty + + - name: Skipped prerelease + if: steps.version_check.outputs.is_stable == 'false' + run: echo "⏭️ Skipped crates.io publish for prerelease version" diff --git a/.gitignore b/.gitignore index 9e122c2..afd6cc7 100644 --- a/.gitignore +++ b/.gitignore @@ -20,8 +20,8 @@ skills-lock.json debug/ target/ -# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries -# texforge is a binary — Cargo.lock is committed intentionally +# Cargo.lock is committed for binaries (not for libraries) +# Remove this line if creating a library # These are backup files generated by rustfmt **/*.rs.bk diff --git a/Cargo.lock b/Cargo.lock index e17eda7..a265fe2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2131,7 +2131,7 @@ dependencies = [ [[package]] name = "texforge" -version = "0.5.0" +version = "0.6.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index a3b3f20..8c5cb98 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,11 @@ [package] name = "texforge" -version = "0.5.0" +version = "0.6.0" edition = "2021" rust-version = "1.75" description = "Self-contained LaTeX to PDF compiler CLI" license = "MIT" -repository = "https://github.com/jheisonmb/texforge" +repository = "https://github.com/UniverLab/texforge" keywords = ["latex", "pdf", "compiler", "cli"] categories = ["command-line-utilities", "development-tools"] diff --git a/README.md b/README.md index 844fe3f..fc78b2b 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,31 @@ ░░░░░░ ``` -[![CI](https://github.com/JheisonMB/texforge/actions/workflows/ci.yml/badge.svg)](https://github.com/JheisonMB/texforge/actions/workflows/ci.yml) -[![Release](https://github.com/JheisonMB/texforge/actions/workflows/release.yml/badge.svg)](https://github.com/JheisonMB/texforge/actions/workflows/release.yml) +[![CI](https://github.com/UniverLab/texforge/actions/workflows/ci.yml/badge.svg)](https://github.com/UniverLab/texforge/actions/workflows/ci.yml) +[![Release](https://github.com/UniverLab/texforge/actions/workflows/release.yml/badge.svg)](https://github.com/UniverLab/texforge/actions/workflows/release.yml) [![Crates.io](https://img.shields.io/crates/v/texforge)](https://crates.io/crates/texforge) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -Self-contained LaTeX to PDF compiler — one curl, zero friction. No TeX Live, no MiKTeX, no Perl, no Node. A single install sets up everything you need. +Texforge is a unified LaTeX workspace — one tool for writing, rendering diagrams (Mermaid, Graphviz), and building PDFs. Set it up once and stay focused on your document. + +--- + +### Demo CLI + +![Demo CLI](assets/texforge.gif) + +--- + +## Features + +- **🚀 One-command setup** — Install once, everything is included (LaTeX engine, templates, diagram renderers). +- **📊 Diagrams as first-class** — Write Mermaid or Graphviz blocks in your `.tex` files; they render and embed during build. +- **🪄 Guided workflows** — Start a new project or migrate an existing one with a guided init. +- **🔎 Template registry** — Install, manage, and validate templates — with built-in fallback for offline work. +- **🔨 Build and live edit** — Compile once or use watch mode; rebuild automatically as you edit. +- **🧭 Smart linting** — Catch missing files, broken references, bibliography keys, and unclosed environments before build. +- **✨ Format on demand** — Normalize `.tex` files with an opinionated formatter (including `--check` mode). +- **🔄 Placeholders and config** — Reuse project details from configuration without retyping. --- @@ -28,13 +47,13 @@ Self-contained LaTeX to PDF compiler — one curl, zero friction. No TeX Live, n **Linux / macOS:** ```bash -curl -fsSL https://raw.githubusercontent.com/JheisonMB/texforge/main/install.sh | sh +curl -fsSL https://raw.githubusercontent.com/UniverLab/texforge/main/scripts/install.sh | sh ``` **Windows (PowerShell):** ```powershell -irm https://raw.githubusercontent.com/JheisonMB/texforge/main/install.ps1 | iex +irm https://raw.githubusercontent.com/UniverLab/texforge/main/scripts/install.ps1 | iex ``` This downloads and installs `texforge`. No Rust toolchain required. Tectonic (the LaTeX engine) is installed automatically on first build. @@ -43,15 +62,15 @@ You can customize the install: ```bash # Pin a specific version -VERSION=0.1.0 curl -fsSL https://raw.githubusercontent.com/JheisonMB/texforge/main/install.sh | sh +VERSION=0.1.0 curl -fsSL https://raw.githubusercontent.com/UniverLab/texforge/main/scripts/install.sh | sh # Install to a custom directory -INSTALL_DIR=/usr/local/bin curl -fsSL https://raw.githubusercontent.com/JheisonMB/texforge/main/install.sh | sh +INSTALL_DIR=/usr/local/bin curl -fsSL https://raw.githubusercontent.com/UniverLab/texforge/main/scripts/install.sh | sh ``` ```powershell # Pin a specific version (PowerShell) -$env:VERSION="0.1.0"; irm https://raw.githubusercontent.com/JheisonMB/texforge/main/install.ps1 | iex +$env:VERSION="0.1.0"; irm https://raw.githubusercontent.com/UniverLab/texforge/main/scripts/install.ps1 | iex ``` ### Via cargo @@ -67,7 +86,7 @@ Available on [crates.io](https://crates.io/crates/texforge). ### From source ```bash -git clone https://github.com/JheisonMB/texforge.git +git clone https://github.com/UniverLab/texforge.git cd texforge cargo build --release # Binary at target/release/texforge @@ -75,7 +94,7 @@ cargo build --release ### GitHub Releases -Check the [Releases](https://github.com/JheisonMB/texforge/releases) page for precompiled binaries (Linux x86_64, macOS x86_64/ARM64, Windows x86_64). +Check the [Releases](https://github.com/UniverLab/texforge/releases) page for precompiled binaries (Linux x86_64, macOS x86_64/ARM64, Windows x86_64). ### Uninstall @@ -86,12 +105,16 @@ rm -rf ~/.texforge/ # tectonic engine + cached templates ## Skill -An [texforge Skill](https://skills.sh/jheisonmb/skills/texforge) is available for AI-assisted LaTeX workflows with texforge: +If you want Copilot to understand texforge and help with common LaTeX tasks, install the [texforge Skill](https://skills.sh/jheisonmb/skills/texforge): ```bash npx skills add https://github.com/jheisonmb/skills --skill texforge ``` +### Demo with OpenCode agents + +![Demo OpenCode](assets/opencode.gif) + ## Quick Start ```bash @@ -164,6 +187,10 @@ texforge init | `texforge fmt` | Format .tex files | | `texforge fmt --check` | Check formatting without modifying | | `texforge check` | Lint without compiling | +| `texforge config` | Interactive wizard to set user details (name, email, institution, language) | +| `texforge config list` | Show all configured values | +| `texforge config ` | Show value for key (name, email, institution, language) | +| `texforge config ` | Set value for key | | `texforge template list` | List installed templates | | `texforge template list --all` | List installed + available in registry | | `texforge template add ` | Download template from registry | @@ -172,9 +199,43 @@ texforge init --- +## Configuration + +Global user details stored in `~/.texforge/config.toml`. These are used as replaceable placeholders in templates. + +**Interactive setup:** + +```bash +texforge config +``` + +This launches a wizard asking for: +- **Name**: Your full name +- **Email**: Your email address +- **Institution**: Your institution/organization +- **Language**: Document language (default: `english`) + +**Command-line interface:** + +```bash +# View all settings +texforge config list + +# Get a specific value +texforge config name + +# Set a value +texforge config name "Jheison Martinez" +texforge config email "jheison@example.com" +texforge config institution "University of Tech" +texforge config language "spanish" +``` + +--- + ## Templates -Templates are managed through the [texforge-templates](https://github.com/JheisonMB/texforge-templates) registry. The `general` template is embedded in the binary and works offline. Run `texforge template list --all` to see all available templates. +Templates are managed through the [texforge-templates](https://github.com/UniverLab/texforge-templates) registry. The `general` template is embedded in the binary and works offline. Run `texforge template list --all` to see all available templates. --- @@ -319,3 +380,14 @@ texforge fmt --check # check without modifying (CI-friendly) ## License MIT + +--- +## Support + +- 📖 [GitHub Issues](https://github.com/UniverLab/texforge/issues) — Report bugs or request features +- 💬 [Discussions](https://github.com/UniverLab/texforge/discussions) — Ask questions +- 🐦 Twitter: [@JheisonMB](https://twitter.com/JheisonMB) + +--- + +Made with ❤️ by [JheisonMB](https://github.com/JheisonMB) and [UniverLab](https://github.com/UniverLab) diff --git a/assets/opencode.gif b/assets/opencode.gif new file mode 100644 index 0000000..06c2c63 Binary files /dev/null and b/assets/opencode.gif differ diff --git a/assets/texforge.gif b/assets/texforge.gif new file mode 100644 index 0000000..a0e3320 Binary files /dev/null and b/assets/texforge.gif differ diff --git a/install.ps1 b/scripts/install.ps1 similarity index 63% rename from install.ps1 rename to scripts/install.ps1 index 79e96fa..40eb8f1 100644 --- a/install.ps1 +++ b/scripts/install.ps1 @@ -1,14 +1,13 @@ # install.ps1 — download and install texforge on Windows -# tectonic (LaTeX engine) is installed automatically on first build -# Usage: irm https://raw.githubusercontent.com/JheisonMB/texforge/main/install.ps1 | iex +# Usage: irm https://raw.githubusercontent.com/UniverLab/texforge/main/scripts/install.ps1 | iex # # Options (set as env vars before running): # $env:VERSION = "0.1.0" # pin a specific version -# $env:INSTALL_DIR = "C:\my\bin" # custom install directory +# $env:INSTALL_DIR = "C:\\my\\bin" # custom install directory $ErrorActionPreference = "Stop" -$Repo = "JheisonMB/texforge" +$Repo = "UniverLab/texforge" $Binary = "texforge.exe" $Target = "x86_64-pc-windows-msvc" $InstallDir = if ($env:INSTALL_DIR) { $env:INSTALL_DIR } else { "$env:USERPROFILE\.local\bin" } @@ -29,10 +28,18 @@ if ($env:VERSION) { $Tag = "v$($env:VERSION)" Info "version" "$Tag (pinned)" } else { - $latest = Invoke-RestMethod "https://api.github.com/repos/$Repo/releases/latest" - $Tag = $latest.tag_name - if (-not $Tag) { Fail "Could not resolve latest release tag" } - Info "version" "$Tag (latest)" + # Get latest stable release (exclude prerelease) + $releases = Invoke-RestMethod "https://api.github.com/repos/$Repo/releases" + $stable = $releases | Where-Object { -not $_.prerelease } | Select-Object -First 1 + if ($stable) { + $Tag = $stable.tag_name + } else { + # Fallback to latest if no stable found + $latest = Invoke-RestMethod "https://api.github.com/repos/$Repo/releases/latest" + $Tag = $latest.tag_name + } + if (-not $Tag) { Fail "Could not resolve latest stable release" } + Info "version" "$Tag (latest stable)" } # --- download --- @@ -50,13 +57,13 @@ try { # --- extract --- Expand-Archive -Path "$Tmp\$Archive" -DestinationPath $Tmp -Force -$extracted = Join-Path $Tmp $Binary +$extracted = Join-Path $Tmp "texforge.exe" if (-not (Test-Path $extracted)) { Fail "Binary not found in archive" } # --- install --- New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null -Copy-Item $extracted "$InstallDir\$Binary" -Force -Info "installed" "$InstallDir\$Binary" +Copy-Item $extracted "$InstallDir\texforge.exe" -Force +Info "installed" "$InstallDir\texforge.exe" # --- ensure PATH --- $userPath = [Environment]::GetEnvironmentVariable("PATH", "User") @@ -70,8 +77,7 @@ if ($userPath -notlike "*$InstallDir*") { Remove-Item $Tmp -Recurse -Force # --- verify --- -$ver = & "$InstallDir\$Binary" --version 2>$null +$ver = & "$InstallDir\texforge.exe" --version 2>$null Info "done" $ver Write-Host "" -Info "ready" "Run 'texforge new my-project' to get started!" -Info "note" "tectonic (LaTeX engine) will be installed automatically on first build" +Info "ready" "Run 'texforge --help' to get started!" diff --git a/install.sh b/scripts/install.sh old mode 100755 new mode 100644 similarity index 77% rename from install.sh rename to scripts/install.sh index cefa3f4..f75e20a --- a/install.sh +++ b/scripts/install.sh @@ -1,10 +1,9 @@ #!/bin/sh # install.sh — download and install texforge from GitHub Releases -# tectonic (LaTeX engine) is installed automatically on first build -# Usage: curl -fsSL https://raw.githubusercontent.com/JheisonMB/texforge/main/install.sh | sh +# Usage: curl -fsSL https://raw.githubusercontent.com/UniverLab/texforge/main/scripts/install.sh | sh set -eu -REPO="JheisonMB/texforge" +REPO="UniverLab/texforge" BINARY="texforge" INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}" @@ -37,18 +36,23 @@ trap 'rm -rf "$TMPDIR"' EXIT # 1. Install texforge # ============================================================ -# --- resolve latest version --- +# --- resolve version --- if [ -n "${VERSION:-}" ]; then TAG="v$VERSION" info "version" "$TAG (pinned)" else - TAG=$(curl -fsSL -o /dev/null -w '%{url_effective}' "https://github.com/$REPO/releases/latest" | rev | cut -d'/' -f1 | rev) - [ -z "$TAG" ] && error "Could not resolve latest release tag" - info "version" "$TAG (latest)" + # Get latest stable release (exclude prerelease) + TAG=$(curl -fsSL "https://api.github.com/repos/$REPO/releases" | grep -i '"tag_name"' | grep -v 'prerelease.*true' | head -1 | cut -d'"' -f4) + if [ -z "$TAG" ]; then + # Fallback to latest if no stable found + TAG=$(curl -fsSL -o /dev/null -w '%{url_effective}' "https://github.com/$REPO/releases/latest" | rev | cut -d'/' -f1 | rev) + fi + [ -z "$TAG" ] && error "Could not resolve latest stable release" + info "version" "$TAG (latest stable)" fi # --- download --- -ARCHIVE="${BINARY}-${TAG}-${TARGET}.tar.gz" +ARCHIVE="$BINARY-${TAG}-${TARGET}.tar.gz" URL="https://github.com/$REPO/releases/download/${TAG}/${ARCHIVE}" info "download" "$URL" @@ -98,5 +102,4 @@ fi info "done" "$($INSTALL_DIR/$BINARY --version 2>/dev/null || echo "$BINARY installed")" echo "" -info "ready" "Run 'texforge new my-project' to get started!" -info "note" "tectonic (LaTeX engine) will be installed automatically on first build" +info "ready" "Run '$BINARY --help' to get started!" diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 07787b4..7d38474 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -49,6 +49,13 @@ enum Commands { #[command(subcommand)] action: TemplateAction, }, + /// Manage global configuration + Config { + /// Key to get/set (name, email, institution, language) + key: Option, + /// Value to set (optional - if omitted, shows current value) + value: Option, + }, } #[derive(Subcommand)] @@ -88,6 +95,13 @@ impl Cli { TemplateAction::Remove { name } => commands::template::remove(&name), TemplateAction::Validate { name } => commands::template::validate(&name), }, + Commands::Config { key, value } => match (key, value) { + (None, None) => commands::config::wizard(), + (Some(k), None) if k == "list" => commands::config::list(), + (Some(k), None) => commands::config::get(&k), + (Some(k), Some(v)) => commands::config::set(&k, &v), + (None, Some(_)) => anyhow::bail!("Cannot set value without a key"), + }, } } } diff --git a/src/commands/config.rs b/src/commands/config.rs new file mode 100644 index 0000000..2e2576e --- /dev/null +++ b/src/commands/config.rs @@ -0,0 +1,198 @@ +//! Configuration commands: simple key access and interactive wizard +//! +//! Usage: +//! texforge config # Interactive wizard +//! texforge config list # List all settings +//! texforge config name # Show value +//! texforge config name "Jheison" # Set value + +use crate::config; +use anyhow::Result; +use inquire::{Select, Text}; + +const AVAILABLE_LANGUAGES: &[&str] = &[ + "english", + "spanish", + "french", + "german", + "portuguese", + "italian", + "dutch", + "russian", + "chinese", + "japanese", +]; + +const BANNER: &str = r#" + ███████████ █████ █████ ███████████ +░█░░░███░░░█ ░░███ ░░███ ░░███░░░░░░█ +░ ░███ ░ ██████ ░░███ ███ ░███ █ ░ ██████ ████████ ███████ ██████ + ░███ ███░░███ ░░█████ ░███████ ███░░███░░███░░███ ███░░███ ███░░███ + ░███ ░███████ ███░███ ░███░░░█ ░███ ░███ ░███ ░░░ ░███ ░███░███████ + ░███ ░███░░░ ███ ░░███ ░███ ░ ░███ ░███ ░███ ░███ ░███░███░░░ + █████ ░░██████ █████ █████ █████ ░░██████ █████ ░░███████░░██████ + ░░░░░ ░░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░░ ░░░░░ ░░░░░███ ░░░░░░ + ███ ░███ + ░░██████ + ░░░░░░ +"#; + +/// Get a config value by key (e.g., "name", "email", "institution", "language") +pub fn get(key: &str) -> Result<()> { + let config = config::load()?; + + match key { + "name" => { + if let Some(name) = &config.user.name { + println!("{}", name); + } else { + println!("(not set)"); + } + } + "email" => { + if let Some(email) = &config.user.email { + println!("{}", email); + } else { + println!("(not set)"); + } + } + "institution" => { + if let Some(inst) = &config.institution.name { + println!("{}", inst); + } else { + println!("(not set)"); + } + } + "language" => { + if let Some(lang) = &config.defaults.language { + println!("{}", lang); + } else { + println!("(not set)"); + } + } + _ => { + anyhow::bail!( + "Unknown config key: {}. Available: name, email, institution, language", + key + ); + } + } + + Ok(()) +} + +/// Set a config value by key +pub fn set(key: &str, value: &str) -> Result<()> { + let mut config = config::load()?; + + match key { + "name" => { + config.user.name = Some(value.to_string()); + } + "email" => { + config.user.email = Some(value.to_string()); + } + "institution" => { + config.institution.name = Some(value.to_string()); + } + "language" => { + config.defaults.language = Some(value.to_string()); + } + _ => { + anyhow::bail!( + "Unknown config key: {}. Available: name, email, institution, language", + key + ); + } + } + + config::save(&config)?; + println!("✓ Set {} = {}", key, value); + Ok(()) +} + +/// List all configuration values +pub fn list() -> Result<()> { + let config = config::load()?; + + println!("Global configuration:\n"); + + println!("[User]"); + if let Some(name) = &config.user.name { + println!(" name = {}", name); + } else { + println!(" name = (not set)"); + } + if let Some(email) = &config.user.email { + println!(" email = {}", email); + } else { + println!(" email = (not set)"); + } + + println!(); + println!("[Institution]"); + if let Some(inst) = &config.institution.name { + println!(" name = {}", inst); + } else { + println!(" name = (not set)"); + } + + println!(); + println!("[Defaults]"); + if let Some(lang) = &config.defaults.language { + println!(" language = {}", lang); + } else { + println!(" language = (not set)"); + } + + Ok(()) +} + +/// Interactive configuration wizard - asks for all 4 main fields +pub fn wizard() -> Result<()> { + println!("{BANNER}"); + println!("Configuration Wizard\n"); + println!("Fill in your details to be used as placeholders in templates:\n"); + + let config = config::load()?; + + let name = Text::new("Name") + .with_default(config.user.name.as_deref().unwrap_or("")) + .prompt()?; + + let email = Text::new("Email") + .with_default(config.user.email.as_deref().unwrap_or("email@domain.com")) + .prompt()?; + + let institution = Text::new("Institution") + .with_default(config.institution.name.as_deref().unwrap_or("")) + .prompt()?; + + let default_lang = config.defaults.language.as_deref().unwrap_or("english"); + let language_options: Vec<&str> = AVAILABLE_LANGUAGES.to_vec(); + + let selected_language = if AVAILABLE_LANGUAGES.contains(&default_lang) { + Select::new("Language", language_options) + .with_help_message("↑↓ move enter confirm") + .prompt_skippable() + .map(|opt| opt.unwrap_or(default_lang)) + } else { + Select::new("Language", language_options) + .with_help_message("↑↓ move enter confirm") + .prompt() + }; + + let language = selected_language?; + + // Save all values + let mut new_config = config::load()?; + new_config.user.name = Some(name); + new_config.user.email = Some(email); + new_config.institution.name = Some(institution); + new_config.defaults.language = Some(language.to_string()); + + config::save(&new_config)?; + + println!("\n✓ Configuration saved!"); + Ok(()) +} diff --git a/src/commands/init.rs b/src/commands/init.rs index fe90fd4..caa3dc0 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -3,10 +3,11 @@ use std::path::Path; use anyhow::Result; -use inquire::{Select, Text}; +use inquire::{Confirm, Select, Text}; use crate::commands::new as new_cmd; use crate::templates; +use crate::version_checker; pub(crate) const BANNER: &str = r#" ███████████ █████ █████ ███████████ @@ -26,6 +27,9 @@ pub(crate) const BANNER: &str = r#" pub fn execute() -> Result<()> { println!("{BANNER}"); + // Check for version updates + check_for_updates()?; + let root = std::env::current_dir()?; if root.join("project.toml").exists() { @@ -155,3 +159,57 @@ fn find_file_by( .map(|p| p.to_string_lossy().to_string()) }) } + +/// Check if a newer stable version is available and prompt user +fn check_for_updates() -> Result<()> { + // Query GitHub API for latest stable release + match version_checker::check_for_updates("UniverLab", "texforge") { + Ok(result) => { + if result.update_available { + if let Some(latest) = &result.latest_stable { + println!( + "\n ℹ A new version of texforge is available: {} → {}", + result.local_version, latest + ); + + let choice = Confirm::new(" Update now?") + .with_default(false) + .prompt() + .unwrap_or(false); + + if choice { + println!("\n ⬇ Downloading texforge {}...", latest); + match download_and_install(latest) { + Ok(_) => { + println!(" ✓ Update complete! Please restart texforge.\n"); + std::process::exit(0); + } + Err(e) => { + eprintln!(" ✗ Update failed: {}", e); + println!(" Manual update: https://github.com/UniverLab/texforge/releases\n"); + } + } + } else { + println!(" (Update skipped)\n"); + } + } + } + } + Err(e) => { + // Silently fail if we can't check for updates (offline, API error, etc) + // Don't interrupt the user's workflow + eprintln!(" (Could not check for updates: {})", e); + } + } + + Ok(()) +} + +/// Download and install a new binary (placeholder for Phase 1) +fn download_and_install(version: &crate::version::SemVer) -> Result<()> { + // Phase 1: Show the download URL and instructions + // Phase 2+: Implement automatic download/install + let url = version_checker::get_release_download_url("UniverLab", "texforge", version); + println!(" Download: {}", url); + anyhow::bail!("Automatic installation not yet implemented. Please download from the URL above.") +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 23f89e6..d2f7934 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod build; pub mod check; pub mod clean; +pub mod config; pub mod fmt; pub mod init; pub mod new; diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..9ebcf40 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,138 @@ +//! Global user configuration system. +//! +//! Stores user preferences in `~/.texforge/config.toml` or `$XDG_CONFIG_HOME/texforge/config.toml`. +//! Supports TOML format with user, institution, defaults, and templates sections. + +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +/// Global configuration structure +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Config { + #[serde(default)] + pub user: UserConfig, + #[serde(default)] + pub institution: InstitutionConfig, + #[serde(default)] + pub defaults: DefaultsConfig, + #[serde(default)] + pub templates: TemplatesConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct UserConfig { + pub name: Option, + pub email: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct InstitutionConfig { + pub name: Option, + pub address: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DefaultsConfig { + pub documentclass: Option, + pub fontsize: Option, + pub papersize: Option, + pub language: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct TemplatesConfig { + pub source: Option, + pub auto_update: Option, + pub watch: Option, +} + +/// Returns the path to the user's texforge config directory. +/// Prefers `$XDG_CONFIG_HOME/texforge`, falls back to `~/.texforge/config.toml`. +#[allow(dead_code)] +fn config_dir() -> Result { + if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME") { + let path = PathBuf::from(xdg_config).join("texforge"); + return Ok(path); + } + + let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?; + Ok(home.join(".texforge")) +} + +/// Returns the path to the config.toml file +pub fn config_file_path() -> Result { + if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME") { + let path = PathBuf::from(xdg_config).join("texforge/config.toml"); + return Ok(path); + } + + let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?; + Ok(home.join(".texforge/config.toml")) +} + +/// Load config from `~/.texforge/config.toml` or `XDG_CONFIG_HOME/texforge/config.toml` +pub fn load() -> Result { + let path = config_file_path()?; + + if !path.exists() { + // Return empty config if file doesn't exist + return Ok(Config::default()); + } + + let content = fs::read_to_string(&path) + .with_context(|| format!("Failed to read config file: {}", path.display()))?; + + toml::from_str(&content).context("Failed to parse config TOML") +} + +/// Save config to `~/.texforge/config.toml` or `XDG_CONFIG_HOME/texforge/config.toml` +pub fn save(config: &Config) -> Result<()> { + let path = config_file_path()?; + let dir = path + .parent() + .ok_or_else(|| anyhow!("Invalid config path"))?; + + // Create directory if it doesn't exist + fs::create_dir_all(dir).context("Failed to create config directory")?; + + let content = toml::to_string_pretty(config).context("Failed to serialize config")?; + + fs::write(&path, content) + .with_context(|| format!("Failed to write config file: {}", path.display()))?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_config() { + let toml_str = r#" +[user] +name = "Jane Doe" +email = "jane@example.com" + +[defaults] +documentclass = "article" +fontsize = "11pt" +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + assert_eq!(config.user.name, Some("Jane Doe".to_string())); + assert_eq!(config.defaults.fontsize, Some("11pt".to_string())); + } + + #[test] + fn test_serialize_config() { + let mut config = Config::default(); + config.user.name = Some("John Doe".to_string()); + config.user.email = Some("john@example.com".to_string()); + + let toml_str = toml::to_string_pretty(&config).unwrap(); + assert!(toml_str.contains("John Doe")); + assert!(toml_str.contains("john@example.com")); + } +} diff --git a/src/diagrams/mod.rs b/src/diagrams/mod.rs index f13295e..9832eee 100644 --- a/src/diagrams/mod.rs +++ b/src/diagrams/mod.rs @@ -212,9 +212,71 @@ fn resolve_tex(root: &Path, input: &str) -> PathBuf { } } +/// Build a font database with system fonts and platform-specific fallbacks. +fn build_fontdb() -> resvg::usvg::fontdb::Database { + use resvg::usvg::fontdb::Database; + + let mut db = Database::new(); + db.load_system_fonts(); + + // On WSL / Windows, also load the Windows font directory + let win_fonts = std::path::Path::new("/mnt/c/Windows/Fonts"); + if win_fonts.is_dir() { + db.load_fonts_dir(win_fonts); + } + + // If the DB still has no fonts at all, try common directories explicitly. + if db.is_empty() { + for dir in ["/usr/share/fonts", "/usr/local/share/fonts"] { + let p = std::path::Path::new(dir); + if p.is_dir() { + db.load_fonts_dir(p); + } + } + } + + // Collect the set of available family names once (avoids borrow conflicts). + let available: std::collections::HashSet = db + .faces() + .flat_map(|f| f.families.iter().map(|(name, _)| name.clone())) + .collect(); + + // Map generic CSS families to the first concrete font we find in the DB. + let sans = ["Arial", "DejaVu Sans", "Liberation Sans", "Noto Sans"]; + if let Some(f) = sans.iter().find(|n| available.contains(**n)) { + db.set_sans_serif_family(*f); + } + let serif = [ + "Times New Roman", + "DejaVu Serif", + "Liberation Serif", + "Noto Serif", + ]; + if let Some(f) = serif.iter().find(|n| available.contains(**n)) { + db.set_serif_family(*f); + } + let mono = [ + "Courier New", + "DejaVu Sans Mono", + "Liberation Mono", + "Noto Sans Mono", + ]; + if let Some(f) = mono.iter().find(|n| available.contains(**n)) { + db.set_monospace_family(*f); + } + + db +} + /// Convert SVG string to PNG bytes at 2x scale for print quality. fn svg_to_png(svg: &str) -> Result> { - let options = resvg::usvg::Options::default(); + let fontdb = build_fontdb(); + + let options = resvg::usvg::Options { + fontdb: std::sync::Arc::new(fontdb), + ..Default::default() + }; + let tree = resvg::usvg::Tree::from_str(svg, &options).context("Failed to parse SVG")?; let scale = 2.0_f32; diff --git a/src/main.rs b/src/main.rs index 39d0a57..a52ea00 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,12 +6,17 @@ mod cli; mod commands; mod compiler; +mod config; mod diagrams; mod domain; mod formatter; mod linter; +mod manifest; +mod placeholders; mod templates; mod utils; +mod version; +mod version_checker; use anyhow::Result; use clap::Parser; diff --git a/src/manifest.rs b/src/manifest.rs new file mode 100644 index 0000000..254a772 --- /dev/null +++ b/src/manifest.rs @@ -0,0 +1,224 @@ +//! Template manifest parser and schema validation. +//! +//! Parses `template.toml` files that describe LaTeX templates with metadata, +//! placeholders, and post-generation scripts. + +#![allow(dead_code)] + +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +/// Template manifest structure matching the Phase 1 spec +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TemplateManifest { + pub id: String, + pub version: String, + pub display_name: String, + pub description: String, + + #[serde(default)] + pub files: FileSpec, + + #[serde(default)] + pub placeholders: Vec, + + #[serde(default)] + pub post_generate: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct FileSpec { + #[serde(default)] + pub include: Vec, + + #[serde(default)] + pub exclude: Vec, +} + +/// A placeholder that can be substituted during template generation +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Placeholder { + pub name: String, + pub r#type: PlaceholderType, + pub description: String, + + #[serde(default)] + pub required: bool, + + #[serde(default)] + pub default: Option, + + #[serde(default)] + pub choices: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum PlaceholderType { + String, + Boolean, + Enum, +} + +/// A post-generate script that the user can optionally run +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PostGenerateScript { + pub name: String, + pub description: String, + pub command: String, + + #[serde(default)] + pub optional: bool, +} + +impl TemplateManifest { + /// Parse a template.toml file + pub fn from_file(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read template.toml: {}", path.display()))?; + + Self::from_str(&content) + } + + /// Parse from TOML string + pub fn from_str(content: &str) -> Result { + let manifest: TemplateManifest = toml::from_str(content) + .context("Failed to parse template.toml — invalid TOML syntax")?; + + // Validate required fields + if manifest.id.is_empty() { + return Err(anyhow!("Template manifest: id is required")); + } + if manifest.version.is_empty() { + return Err(anyhow!("Template manifest: version is required")); + } + if manifest.display_name.is_empty() { + return Err(anyhow!("Template manifest: display_name is required")); + } + + // Validate placeholders + let mut placeholder_names = std::collections::HashSet::new(); + for ph in &manifest.placeholders { + if ph.name.is_empty() { + return Err(anyhow!( + "Template manifest: placeholder name cannot be empty" + )); + } + if !placeholder_names.insert(&ph.name) { + return Err(anyhow!( + "Template manifest: duplicate placeholder name '{}'", + ph.name + )); + } + + // Enum type must have choices + if ph.r#type == PlaceholderType::Enum && ph.choices.is_none() { + return Err(anyhow!( + "Template manifest: enum placeholder '{}' must have 'choices'", + ph.name + )); + } + } + + Ok(manifest) + } + + /// Get a placeholder by name + pub fn get_placeholder(&self, name: &str) -> Option<&Placeholder> { + self.placeholders.iter().find(|p| p.name == name) + } + + /// Get all required placeholders + pub fn required_placeholders(&self) -> Vec<&Placeholder> { + self.placeholders.iter().filter(|p| p.required).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_simple_manifest() { + let toml = r#" +id = "basic-article" +version = "1.0.0" +display_name = "Basic Article" +description = "A simple article template" + +[[placeholders]] +name = "title" +type = "string" +description = "Document title" +required = true +"#; + let manifest = TemplateManifest::from_str(toml).unwrap(); + assert_eq!(manifest.id, "basic-article"); + assert_eq!(manifest.placeholders.len(), 1); + assert!(manifest.get_placeholder("title").is_some()); + } + + #[test] + fn test_parse_with_default() { + let toml = r#" +id = "test" +version = "1.0.0" +display_name = "Test" +description = "Test template" + +[[placeholders]] +name = "author" +type = "string" +description = "Author name" +default = "{{user.name}}" +"#; + let manifest = TemplateManifest::from_str(toml).unwrap(); + let author = manifest.get_placeholder("author").unwrap(); + assert_eq!(author.default, Some("{{user.name}}".to_string())); + } + + #[test] + fn test_enum_placeholder_requires_choices() { + let toml = r#" +id = "test" +version = "1.0.0" +display_name = "Test" +description = "Test" + +[[placeholders]] +name = "language" +type = "enum" +description = "Document language" +"#; + let result = TemplateManifest::from_str(toml); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("must have 'choices'")); + } + + #[test] + fn test_duplicate_placeholder_names() { + let toml = r#" +id = "test" +version = "1.0.0" +display_name = "Test" +description = "Test" + +[[placeholders]] +name = "title" +type = "string" +description = "Title" + +[[placeholders]] +name = "title" +type = "string" +description = "Duplicate" +"#; + let result = TemplateManifest::from_str(toml); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("duplicate")); + } +} diff --git a/src/placeholders.rs b/src/placeholders.rs new file mode 100644 index 0000000..5901a57 --- /dev/null +++ b/src/placeholders.rs @@ -0,0 +1,291 @@ +//! Placeholder resolution engine with 5-level precedence chain. +//! +//! Resolves `{{placeholder}}` tokens in template files according to: +//! 1. CLI arguments +//! 2. Project config (`./.texforge/config.toml`) +//! 3. User config (`~/.texforge/config.toml`) +//! 4. Template defaults (from `template.toml`) +//! 5. Interactive prompt (if required and no value found) + +#![allow(dead_code)] + +use crate::config; +use crate::manifest::Placeholder; +use anyhow::{anyhow, Result}; +use std::collections::HashMap; + +/// Placeholder resolver with precedence chain +pub struct PlaceholderResolver { + /// Values from CLI (highest priority) + cli_args: HashMap, + /// Values from project config + project_config: HashMap, + /// Values from user config (loaded from ~/.texforge/config.toml) + user_config: Option, +} + +impl PlaceholderResolver { + /// Create a new resolver + pub fn new(cli_args: HashMap) -> Self { + let user_config = config::load().ok(); + let project_config = load_project_config().unwrap_or_default(); + + Self { + cli_args, + project_config, + user_config, + } + } + + /// Resolve a placeholder value using the 5-level precedence chain + /// Returns None if not found, Err if resolution fails + pub fn resolve(&self, placeholder: &Placeholder) -> Result> { + // 1. Check CLI arguments first + if let Some(value) = self.cli_args.get(&placeholder.name) { + return Ok(Some(value.clone())); + } + + // 2. Check project config + if let Some(value) = self.project_config.get(&placeholder.name) { + return Ok(Some(value.clone())); + } + + // 3. Check user config + if let Some(user_cfg) = &self.user_config { + if let Some(value) = self.resolve_from_user_config(user_cfg, &placeholder.name) { + return Ok(Some(value)); + } + } + + // 4. Check template default + if let Some(default) = &placeholder.default { + let resolved = self.resolve_interpolations(default)?; + return Ok(Some(resolved)); + } + + // If required and not found, error (caller should prompt) + // If optional, return None + Ok(None) + } + + /// Resolve all placeholders in a set, filling required ones or erroring + pub fn resolve_all(&self, placeholders: &[Placeholder]) -> Result> { + let mut result = HashMap::new(); + + for ph in placeholders { + let resolved = self.resolve(ph)?; + if let Some(value) = resolved { + result.insert(ph.name.clone(), value); + } else if ph.required { + return Err(anyhow!( + "Required placeholder '{}' has no value ({})", + ph.name, + ph.description + )); + } + } + + Ok(result) + } + + /// Replace {{placeholder}} tokens in content + pub fn substitute(&self, content: &str, values: &HashMap) -> Result { + let mut result = content.to_string(); + + for (key, value) in values { + let token = format!("{{{{{}}}}}", key); + result = result.replace(&token, value); + } + + // Check for unresolved tokens + if result.contains("{{") { + return Err(anyhow!("Unresolved placeholders found in content")); + } + + Ok(result) + } + + /// Resolve {{user.name}} style interpolations in defaults + fn resolve_interpolations(&self, text: &str) -> Result { + let mut result = text.to_string(); + + // {{user.name}} + if let Some(cfg) = &self.user_config { + if let Some(name) = &cfg.user.name { + result = result.replace("{{user.name}}", name); + } + } + + // {{user.email}} + if let Some(cfg) = &self.user_config { + if let Some(email) = &cfg.user.email { + result = result.replace("{{user.email}}", email); + } + } + + // {{institution.name}} + if let Some(cfg) = &self.user_config { + if let Some(name) = &cfg.institution.name { + result = result.replace("{{institution.name}}", name); + } + } + + Ok(result) + } + + /// Extract value from user config by placeholder name (convention: section.key) + fn resolve_from_user_config(&self, cfg: &config::Config, placeholder: &str) -> Option { + // Try direct match in each section + if let Some(name) = &cfg.user.name { + if placeholder == "author" || placeholder == "user.name" { + return Some(name.clone()); + } + } + if let Some(email) = &cfg.user.email { + if placeholder == "email" || placeholder == "user.email" { + return Some(email.clone()); + } + } + + if let Some(name) = &cfg.institution.name { + if placeholder == "institution" || placeholder == "institution.name" { + return Some(name.clone()); + } + } + + if let Some(dc) = &cfg.defaults.documentclass { + if placeholder == "documentclass" { + return Some(dc.clone()); + } + } + if let Some(lang) = &cfg.defaults.language { + if placeholder == "language" { + return Some(lang.clone()); + } + } + + None + } +} + +/// Load project-level config from ./.texforge/config.toml +fn load_project_config() -> Result> { + let path = std::path::PathBuf::from(".texforge/config.toml"); + if !path.exists() { + return Ok(HashMap::new()); + } + + let content = std::fs::read_to_string(&path)?; + let values: toml::Table = toml::from_str(&content)?; + let mut result = HashMap::new(); + + // Flatten the TOML structure into a simple map + flatten_toml(&values, "", &mut result); + + Ok(result) +} + +/// Flatten nested TOML into key.subkey format +fn flatten_toml(table: &toml::Table, prefix: &str, result: &mut HashMap) { + for (key, value) in table.iter() { + let full_key = if prefix.is_empty() { + key.to_string() + } else { + format!("{}.{}", prefix, key) + }; + + match value { + toml::Value::String(s) => { + result.insert(full_key, s.clone()); + } + toml::Value::Table(t) => { + flatten_toml(t, &full_key, result); + } + toml::Value::Boolean(b) => { + result.insert(full_key, b.to_string()); + } + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::manifest::{Placeholder, PlaceholderType}; + + fn make_placeholder(name: &str, required: bool) -> Placeholder { + Placeholder { + name: name.to_string(), + r#type: PlaceholderType::String, + description: "test".to_string(), + required, + default: None, + choices: None, + } + } + + #[test] + fn test_resolve_cli_priority() { + let mut cli_args = HashMap::new(); + cli_args.insert("title".to_string(), "My Title".to_string()); + + let resolver = PlaceholderResolver { + cli_args, + project_config: HashMap::new(), + user_config: None, + }; + + let ph = make_placeholder("title", true); + let result = resolver.resolve(&ph).unwrap(); + assert_eq!(result, Some("My Title".to_string())); + } + + #[test] + fn test_resolve_missing_required() { + let resolver = PlaceholderResolver { + cli_args: HashMap::new(), + project_config: HashMap::new(), + user_config: None, + }; + + let mut ph = make_placeholder("title", true); + ph.default = None; + + let result = resolver.resolve_all(&[ph]); + assert!(result.is_err()); + } + + #[test] + fn test_substitute_tokens() { + let resolver = PlaceholderResolver { + cli_args: HashMap::new(), + project_config: HashMap::new(), + user_config: None, + }; + + let mut values = HashMap::new(); + values.insert("title".to_string(), "My Document".to_string()); + values.insert("author".to_string(), "Jane Doe".to_string()); + + let content = "\\title{{{title}}}\n\\author{{{author}}}"; + let result = resolver.substitute(content, &values).unwrap(); + + assert_eq!(result, "\\title{My Document}\n\\author{Jane Doe}"); + } + + #[test] + fn test_unresolved_tokens_error() { + let resolver = PlaceholderResolver { + cli_args: HashMap::new(), + project_config: HashMap::new(), + user_config: None, + }; + + let values = HashMap::new(); + let content = "\\title{{{title}}}"; + let result = resolver.substitute(content, &values); + + assert!(result.is_err()); + } +} diff --git a/src/version.rs b/src/version.rs new file mode 100644 index 0000000..0343ec4 --- /dev/null +++ b/src/version.rs @@ -0,0 +1,171 @@ +//! Semantic versioning utilities for version comparison and stability detection. + +use std::cmp::Ordering; + +/// A semantic version parsed as `(major, minor, patch, prerelease_suffix)` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SemVer { + pub major: u32, + pub minor: u32, + pub patch: u32, + pub prerelease: Option, +} + +impl SemVer { + /// Parse a version string like "1.2.3" or "1.2.3-alpha" into a `SemVer`. + /// Handles common prefixes like "v1.2.3". + pub fn parse(s: &str) -> Option { + let s = s.trim_start_matches('v'); + + // Split on '-' to separate prerelease + let (version_part, prerelease) = if let Some(idx) = s.find('-') { + let (v, p) = s.split_at(idx); + (v, Some(p[1..].to_string())) // skip the '-' + } else { + (s, None) + }; + + // Parse major.minor.patch + let parts: Vec<&str> = version_part.split('.').collect(); + if parts.len() != 3 { + return None; + } + + let major = parts[0].parse::().ok()?; + let minor = parts[1].parse::().ok()?; + let patch = parts[2].parse::().ok()?; + + Some(SemVer { + major, + minor, + patch, + prerelease, + }) + } + + /// Check if this version is stable (no prerelease suffix). + #[allow(dead_code)] + pub fn is_stable(&self) -> bool { + self.prerelease.is_none() + } + + /// Compare two versions. Returns `Ordering::Greater` if self > other. + /// Stable versions are considered greater than prerelease versions with same base. + pub fn compare(&self, other: &SemVer) -> Ordering { + // First compare base version (major.minor.patch) + match self.major.cmp(&other.major) { + Ordering::Equal => {} + other_order => return other_order, + } + match self.minor.cmp(&other.minor) { + Ordering::Equal => {} + other_order => return other_order, + } + match self.patch.cmp(&other.patch) { + Ordering::Equal => {} + other_order => return other_order, + } + + // Base version is equal. Compare prerelease. + // Stable > prerelease for same base version + match (&self.prerelease, &other.prerelease) { + (None, None) => Ordering::Equal, // both stable + (None, Some(_)) => Ordering::Greater, // self stable, other prerelease + (Some(_), None) => Ordering::Less, // self prerelease, other stable + (Some(a), Some(b)) => a.cmp(b), // both prerelease, compare strings + } + } +} + +impl PartialOrd for SemVer { + fn partial_cmp(&self, other: &SemVer) -> Option { + Some(std::cmp::Ord::cmp(self, other)) + } +} + +impl Ord for SemVer { + fn cmp(&self, other: &SemVer) -> Ordering { + self.compare(other) + } +} + +impl std::fmt::Display for SemVer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}.{}.{}", self.major, self.minor, self.patch)?; + if let Some(pre) = &self.prerelease { + write!(f, "-{}", pre)?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_simple() { + let v = SemVer::parse("1.2.3").unwrap(); + assert_eq!(v.major, 1); + assert_eq!(v.minor, 2); + assert_eq!(v.patch, 3); + assert!(v.is_stable()); + } + + #[test] + fn test_parse_with_v_prefix() { + let v = SemVer::parse("v1.2.3").unwrap(); + assert_eq!(v.major, 1); + assert_eq!(v.minor, 2); + assert_eq!(v.patch, 3); + } + + #[test] + fn test_parse_prerelease() { + let v = SemVer::parse("1.2.3-alpha").unwrap(); + assert_eq!(v.major, 1); + assert_eq!(v.prerelease, Some("alpha".to_string())); + assert!(!v.is_stable()); + } + + #[test] + fn test_parse_invalid() { + assert!(SemVer::parse("1.2").is_none()); + assert!(SemVer::parse("a.b.c").is_none()); + } + + #[test] + fn test_compare_major() { + let v1 = SemVer::parse("2.0.0").unwrap(); + let v2 = SemVer::parse("1.9.9").unwrap(); + assert!(v1 > v2); + } + + #[test] + fn test_compare_minor() { + let v1 = SemVer::parse("1.2.0").unwrap(); + let v2 = SemVer::parse("1.1.9").unwrap(); + assert!(v1 > v2); + } + + #[test] + fn test_compare_patch() { + let v1 = SemVer::parse("1.1.2").unwrap(); + let v2 = SemVer::parse("1.1.1").unwrap(); + assert!(v1 > v2); + } + + #[test] + fn test_stable_vs_prerelease() { + let stable = SemVer::parse("1.2.3").unwrap(); + let prerelease = SemVer::parse("1.2.3-alpha").unwrap(); + assert!(stable > prerelease); + } + + #[test] + fn test_equal() { + let v1 = SemVer::parse("1.2.3").unwrap(); + let v2 = SemVer::parse("1.2.3").unwrap(); + assert_eq!(v1, v2); + } +} diff --git a/src/version_checker.rs b/src/version_checker.rs new file mode 100644 index 0000000..78cd060 --- /dev/null +++ b/src/version_checker.rs @@ -0,0 +1,141 @@ +//! GitHub releases version checker for auto-update detection. +//! +//! Queries the GitHub API to detect new stable versions and compare with local version. + +#![allow(dead_code)] + +use crate::version::SemVer; +use anyhow::{anyhow, Context, Result}; +use serde::Deserialize; + +/// GitHub API release response (minimal fields) +#[derive(Debug, Deserialize)] +struct GitHubRelease { + tag_name: String, + prerelease: bool, + draft: bool, +} + +/// Version check result +#[derive(Debug, Clone)] +pub struct VersionCheckResult { + pub local_version: SemVer, + pub latest_stable: Option, + pub update_available: bool, +} + +/// Check for newer stable versions on GitHub +pub fn check_for_updates(owner: &str, repo: &str) -> Result { + let local = get_local_version()?; + + // Query GitHub API for releases + let latest_stable = fetch_latest_stable_release(owner, repo)?; + + let update_available = if let Some(ref remote) = latest_stable { + remote > &local + } else { + false + }; + + Ok(VersionCheckResult { + local_version: local, + latest_stable, + update_available, + }) +} + +/// Get the current texforge version (from Cargo.toml at compile time) +pub fn get_local_version() -> Result { + let version_str = env!("CARGO_PKG_VERSION"); + SemVer::parse(version_str) + .ok_or_else(|| anyhow!("Failed to parse local version: {}", version_str)) +} + +/// Fetch latest stable release from GitHub +/// Filters out pre-releases and drafts +fn fetch_latest_stable_release(owner: &str, repo: &str) -> Result> { + let url = format!("https://api.github.com/repos/{}/{}/releases", owner, repo); + + let client = reqwest::blocking::Client::new(); + let response = client + .get(&url) + .header("User-Agent", "texforge") + .send() + .context("Failed to query GitHub API")?; + + if !response.status().is_success() { + return Err(anyhow!( + "GitHub API returned status {}: {}", + response.status(), + response.text().unwrap_or_default() + )); + } + + let releases: Vec = response + .json() + .context("Failed to parse GitHub releases JSON")?; + + // Find the latest stable version (skip pre-releases and drafts) + for release in releases { + if !release.draft && !release.prerelease { + // Remove 'v' prefix if present + let tag = release.tag_name.trim_start_matches('v'); + if let Some(version) = SemVer::parse(tag) { + return Ok(Some(version)); + } + } + } + + Ok(None) +} + +/// Get the download URL for a specific release +pub fn get_release_download_url(owner: &str, repo: &str, version: &SemVer) -> String { + let arch = get_architecture(); + let _os = get_os(); + let filename = format!("{}-{}-{}", repo, version, arch); + + format!( + "https://github.com/{}/{}/releases/download/v{}/{}", + owner, repo, version, filename + ) +} + +fn get_architecture() -> &'static str { + #[cfg(target_arch = "x86_64")] + return "x86_64"; + #[cfg(target_arch = "aarch64")] + return "aarch64"; + #[cfg(target_arch = "arm")] + return "arm"; +} + +fn get_os() -> &'static str { + #[cfg(target_os = "linux")] + { + "linux" + } + #[cfg(target_os = "macos")] + { + "macos" + } + #[cfg(target_os = "windows")] + { + "windows" + } + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + { + "unknown" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_local_version() { + let version = get_local_version().unwrap(); + assert!(version.major > 0 || version.minor > 0 || version.patch > 0); + } +}