diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..497bd73 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + + - name: Test + run: cargo test --release + + - name: Clippy + run: cargo clippy --release -- -D warnings + + fmt: + name: Format Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + + - name: Format + run: cargo fmt -- --check diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml deleted file mode 100644 index 9fd45e0..0000000 --- a/.github/workflows/rust.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Rust - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -env: - CARGO_TERM_COLOR: always - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - name: Build - run: cargo build --verbose - - name: Run tests - run: cargo test --verbose diff --git a/.gitignore b/.gitignore index 48c5c14..709b846 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,9 @@ node_modules/ package-lock.json yarn.lock + +# Nix development +result +result-* +.dirlocals +.envrc diff --git a/.nix/README.md b/.nix/README.md new file mode 100644 index 0000000..923509b --- /dev/null +++ b/.nix/README.md @@ -0,0 +1,20 @@ +# Nix Configuration Files + +This directory contains supplementary Nix configurations for advanced use cases. + +## Files + +- `shell.nix` - (Optional) Legacy shell.nix for compatibility +- `default.nix` - (Optional) Legacy default.nix for compatibility + +## Using flake.nix (Recommended) + +Use the modern flake.nix at the root instead: + +```bash +nix develop # Enter dev shell +nix build # Build package +nix build .#docker # Build Docker image +``` + +See [docs/NIX.md](../docs/NIX.md) for full documentation. diff --git a/.nix/default.nix b/.nix/default.nix new file mode 100644 index 0000000..2ec28df --- /dev/null +++ b/.nix/default.nix @@ -0,0 +1,5 @@ +# Legacy Nix support (for non-flake setups) +# This file allows `nix-build` to work without flakes +# Usage: nix-build .nix/ + +(import {}).callPackage ./package.nix { } diff --git a/.nix/shell.nix b/.nix/shell.nix new file mode 100644 index 0000000..1d67917 --- /dev/null +++ b/.nix/shell.nix @@ -0,0 +1,24 @@ +# Legacy shell.nix for non-flake Nix users +# Usage: nix-shell .nix/shell.nix + +let + nixpkgs = import { }; +in + +nixpkgs.mkShell { + buildInputs = with nixpkgs; [ + rustup + pkg-config + openssl + cargo-watch + cargo-edit + cargo-outdated + ]; + + shellHook = '' + echo "šŸ§™ Langsmith dev environment (legacy nix-shell)" + echo "Run: cargo build, cargo test, cargo run -- --help" + echo "" + echo "āš ļø Note: Flakes are recommended. Use 'nix develop' instead." + ''; +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..d2bdc4a --- /dev/null +++ b/flake.lock @@ -0,0 +1,170 @@ +{ + "nodes": { + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1767039857, + "narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=", + "owner": "NixOS", + "repo": "flake-compat", + "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "pre-commit-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1768564909, + "narHash": "sha256-Kell/SpJYVkHWMvnhqJz/8DqQg2b6PguxVWOuadbHCc=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e4bae1bd10c9c57b2cf517953ab70060a828ee6f", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1764947035, + "narHash": "sha256-EYHSjVM4Ox4lvCXUMiKKs2vETUSL5mx+J2FfutM7T9w=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "a672be65651c80d3f592a89b3945466584a22069", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_3": { + "locked": { + "lastModified": 1744536153, + "narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "pre-commit-hooks": { + "inputs": { + "flake-compat": "flake-compat", + "gitignore": "gitignore", + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1767281941, + "narHash": "sha256-6MkqajPICgugsuZ92OMoQcgSHnD6sJHwk8AxvMcIgTE=", + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "rev": "f0927703b7b1c8d97511c4116eb9b4ec6645a0fa", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "pre-commit-hooks": "pre-commit-hooks", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": "nixpkgs_3" + }, + "locked": { + "lastModified": 1768704795, + "narHash": "sha256-Y33TAp2BHEcuspYvcmBXXD0qdvjftv73PwyKTDOjoSY=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "4b7472a78857ac789fb26616040f55cfcbd36c6e", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..a96f7b8 --- /dev/null +++ b/flake.nix @@ -0,0 +1,90 @@ +{ + description = "Langsmith - Automatic i18n extraction and translation CLI"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + rust-overlay.url = "github:oxalica/rust-overlay"; + pre-commit-hooks.url = "github:cachix/pre-commit-hooks.nix"; + }; + + outputs = { self, nixpkgs, flake-utils, rust-overlay, pre-commit-hooks }: + flake-utils.lib.eachDefaultSystem (system: + let + overlays = [ (import rust-overlay) ]; + pkgs = import nixpkgs { + inherit system overlays; + }; + + rustToolchain = pkgs.rust-bin.stable.latest.default.override { + extensions = [ "rust-src" "rust-analyzer" ]; + }; + + version = "0.1.0"; + + in + { + # Development shell with all tools + devShells.default = pkgs.mkShell { + buildInputs = with pkgs; [ + rustToolchain + cargo-watch + cargo-edit + cargo-outdated + cargo-deny + cargo-audit + pkg-config + openssl + pre-commit + git + ]; + + shellHook = '' + echo "šŸ§™ Langsmith dev environment loaded (Nix)" + echo "Run: cargo build, cargo test, cargo run -- --help" + ''; + }; + + # Production build (for current system) + packages.default = pkgs.rustPlatform.buildRustPackage { + name = "langsmith"; + inherit version; + src = ./.; + cargoLock.lockFile = ./Cargo.lock; + + buildInputs = with pkgs; [ + pkg-config + openssl + ]; + + meta = with pkgs.lib; { + description = "Automatic i18n extraction and translation CLI"; + homepage = "https://github.com/chahinebenlahcen/langsmith"; + license = licenses.mit; + maintainers = []; + mainProgram = "langsmith"; + }; + }; + + # Docker image + packages.docker = pkgs.dockerTools.buildLayeredImage { + name = "langsmith"; + tag = version; + contents = [ self.packages.${system}.default pkgs.cacert ]; + config = { + Cmd = [ "${self.packages.${system}.default}/bin/langsmith" ]; + Env = [ "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" ]; + }; + }; + + # CLI app + apps.default = { + type = "app"; + program = "${self.packages.${system}.default}/bin/langsmith"; + }; + + # Quality checks (disabled in flake check, available in nix develop) + checks = { }; + } + ); +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..91b7a29 --- /dev/null +++ b/renovate.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:base", + ":dependencyDashboard", + ":semanticCommits", + "group:allNonMajor" + ], + "nix": { + "enabled": true + }, + "cargo": { + "enabled": true + }, + "schedule": ["before 3am on Monday"], + "automerge": false, + "labels": ["dependencies"], + "reviewers": ["@chahinebenlahcen"], + "packageRules": [ + { + "matchDatasources": ["crate"], + "matchUpdateTypes": ["patch"], + "automerge": true + }, + { + "matchDatasources": ["github-tags"], + "matchUpdateTypes": ["patch"], + "automerge": true + } + ] +} diff --git a/src/application/extract_strings.rs b/src/application/extract_strings.rs index 2cc4ae7..52f0a78 100644 --- a/src/application/extract_strings.rs +++ b/src/application/extract_strings.rs @@ -1,7 +1,7 @@ -use crate::domain::ports::{StringExtractor, FileWriter, FileScanner}; use crate::domain::models::LanguageFile; -use std::path::Path; +use crate::domain::ports::{FileScanner, FileWriter, StringExtractor}; use std::collections::HashMap; +use std::path::Path; /// Use case: Extract all translatable strings from a codebase #[allow(dead_code)] @@ -39,7 +39,9 @@ impl ExtractStringsUseCase { } let output_file = output_path.join(format!("{}.json", base_language)); - writer.write_language_file(&output_file, &language_file).await?; + writer + .write_language_file(&output_file, &language_file) + .await?; Ok(()) } diff --git a/src/application/merge_i18n.rs b/src/application/merge_i18n.rs index f01a07f..5297ce3 100644 --- a/src/application/merge_i18n.rs +++ b/src/application/merge_i18n.rs @@ -1,7 +1,7 @@ +use owo_colors::OwoColorize; +use std::collections::HashMap; use std::path::{Path, PathBuf}; use tokio::fs; -use std::collections::HashMap; -use owo_colors::OwoColorize; pub struct MergeI18nUseCase; @@ -61,7 +61,10 @@ impl MergeI18nUseCase { /// Displays a preview of changes to be merged pub fn show_preview(summary: &MergeSummary) { - println!("\n Summary: {} .i18n.* files ready to merge\n", summary.total_files); + println!( + "\n Summary: {} .i18n.* files ready to merge\n", + summary.total_files + ); if summary.files_to_merge.is_empty() { println!(" No .i18n.* files found to merge."); @@ -146,10 +149,12 @@ impl MergeI18nUseCase { fn get_original_path(i18n_path: &Path) -> anyhow::Result { let file_name = i18n_path .file_name() - .ok_or_else(|| anyhow::anyhow!( - "Cannot extract file name from path: {}", - i18n_path.display() - ))? + .ok_or_else(|| { + anyhow::anyhow!( + "Cannot extract file name from path: {}", + i18n_path.display() + ) + })? .to_string_lossy(); // Find .i18n. and remove it with the extension after @@ -158,12 +163,12 @@ impl MergeI18nUseCase { let ext_start = i18n_index + 6; // ".i18n." length let extension = &file_name[ext_start..]; - let parent = i18n_path - .parent() - .ok_or_else(|| anyhow::anyhow!( + let parent = i18n_path.parent().ok_or_else(|| { + anyhow::anyhow!( "Cannot extract parent directory from path: {}", i18n_path.display() - ))?; + ) + })?; Ok(parent.join(format!("{}.{}", base_name, extension))) } else { diff --git a/src/application/mod.rs b/src/application/mod.rs index 206a687..ff26faa 100644 --- a/src/application/mod.rs +++ b/src/application/mod.rs @@ -1,11 +1,11 @@ pub mod extract_strings; -pub mod translate_keys; -pub mod replace_strings; pub mod merge_i18n; +pub mod replace_strings; +pub mod translate_keys; pub use extract_strings::ExtractStringsUseCase; -pub use translate_keys::TranslateKeysUseCase; -#[allow(unused_imports)] -pub use replace_strings::ReplaceStringsUseCase; #[allow(unused_imports)] pub use merge_i18n::MergeI18nUseCase; +#[allow(unused_imports)] +pub use replace_strings::ReplaceStringsUseCase; +pub use translate_keys::TranslateKeysUseCase; diff --git a/src/application/replace_strings.rs b/src/application/replace_strings.rs index 357335d..5ef4c19 100644 --- a/src/application/replace_strings.rs +++ b/src/application/replace_strings.rs @@ -1,12 +1,13 @@ use crate::domain::models::*; use crate::domain::ports::*; -use std::path::{Path, PathBuf}; use std::collections::HashMap; +use std::path::{Path, PathBuf}; use tokio::fs; pub struct ReplaceStringsUseCase; impl ReplaceStringsUseCase { + #[allow(clippy::too_many_arguments)] pub async fn execute( source_path: &Path, translation_file: &Path, @@ -40,11 +41,14 @@ impl ReplaceStringsUseCase { } // Replace strings - let mut content = replacer.replace_in_file(&file_path, &keys_to_replace, &strategy) + let mut content = replacer + .replace_in_file(&file_path, &keys_to_replace, &strategy) .await?; // Add imports - content = import_mgr.ensure_import(&content, file_type, &strategy).await?; + content = import_mgr + .ensure_import(&content, file_type, &strategy) + .await?; if dry_run { println!( @@ -53,7 +57,10 @@ impl ReplaceStringsUseCase { file_path.display() ); for key in &keys_to_replace { - println!(" - Line {}: \"{}\" -> t(\"{}\")", key.line, key.source, key.id); + println!( + " - Line {}: \"{}\" -> t(\"{}\")", + key.line, key.source, key.id + ); } continue; } @@ -92,26 +99,27 @@ impl ReplaceStringsUseCase { fn create_i18n_filename(path: &Path) -> anyhow::Result { let stem = path .file_stem() - .ok_or_else(|| anyhow::anyhow!( - "Cannot extract file stem from path: {}", - path.display() - ))? + .ok_or_else(|| { + anyhow::anyhow!("Cannot extract file stem from path: {}", path.display()) + })? .to_string_lossy(); let ext = path .extension() - .ok_or_else(|| anyhow::anyhow!( - "Cannot extract file extension from path: {}", - path.display() - ))? + .ok_or_else(|| { + anyhow::anyhow!( + "Cannot extract file extension from path: {}", + path.display() + ) + })? .to_string_lossy(); - let parent = path - .parent() - .ok_or_else(|| anyhow::anyhow!( + let parent = path.parent().ok_or_else(|| { + anyhow::anyhow!( "Cannot extract parent directory from path: {}", path.display() - ))?; + ) + })?; Ok(parent.join(format!("{}.i18n.{}", stem, ext))) } diff --git a/src/application/translate_keys.rs b/src/application/translate_keys.rs index b91cde6..e8ddb0f 100644 --- a/src/application/translate_keys.rs +++ b/src/application/translate_keys.rs @@ -1,7 +1,7 @@ -use crate::domain::ports::Translator; use crate::domain::models::LanguageFile; -use std::path::Path; +use crate::domain::ports::Translator; use std::collections::HashMap; +use std::path::Path; /// Use case: Translate extracted strings to target languages pub struct TranslateKeysUseCase; @@ -51,10 +51,12 @@ impl TranslateKeysUseCase { // 3. Write translated file let output_file = source_file .parent() - .ok_or_else(|| anyhow::anyhow!( - "Cannot extract parent directory from path: {}", - source_file.display() - ))? + .ok_or_else(|| { + anyhow::anyhow!( + "Cannot extract parent directory from path: {}", + source_file.display() + ) + })? .join(format!("{}.json", target_lang)); let json = serde_json::to_string_pretty(&translated.translations)?; diff --git a/src/cli/commands/extract.rs b/src/cli/commands/extract.rs index faaf4b2..1fac5d2 100644 --- a/src/cli/commands/extract.rs +++ b/src/cli/commands/extract.rs @@ -1,8 +1,8 @@ -use clap::Parser; -use std::path::PathBuf; use crate::application::ExtractStringsUseCase; -use crate::infrastructure::{FileSystemScanner, FileSystemWriter, SwcStringExtractor}; use crate::cli::presenter::Presenter; +use crate::infrastructure::{FileSystemScanner, FileSystemWriter, SwcStringExtractor}; +use clap::Parser; +use std::path::PathBuf; #[derive(Parser, Debug)] pub struct ExtractCmd { diff --git a/src/cli/commands/merge.rs b/src/cli/commands/merge.rs index 25e262f..075d5a1 100644 --- a/src/cli/commands/merge.rs +++ b/src/cli/commands/merge.rs @@ -1,7 +1,7 @@ -use clap::Parser; -use std::path::PathBuf; use crate::application::merge_i18n::MergeI18nUseCase; +use clap::Parser; use owo_colors::OwoColorize; +use std::path::PathBuf; #[derive(Parser, Debug)] pub struct MergeCmd { @@ -35,19 +35,29 @@ impl MergeCmd { // Show preview if !self.confirm { - println!("\n {} Preview Mode - No files will be modified", "ℹ".cyan()); + println!( + "\n {} Preview Mode - No files will be modified", + "ℹ".cyan() + ); MergeI18nUseCase::show_preview(&summary); return Ok(()); } // Actually perform merge - println!("\n {} Merging {} files...", "ā³".cyan(), summary.files_to_merge.len()); + println!( + "\n {} Merging {} files...", + "ā³".cyan(), + summary.files_to_merge.len() + ); let result = MergeI18nUseCase::execute_merge(&summary).await?; // Show results println!("\n {} Merge Complete!", "āœ“".green()); - println!(" {} files merged successfully", result.successful.to_string().green()); - + println!( + " {} files merged successfully", + result.successful.to_string().green() + ); + if result.failed > 0 { println!(" {} files failed", result.failed.to_string().red()); println!("\n Errors:"); diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index f4bc569..b39f47d 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -1,8 +1,8 @@ pub mod extract; -pub mod translate; -pub mod replace; pub mod merge; +pub mod replace; pub mod setup; +pub mod translate; use clap::Subcommand; diff --git a/src/cli/commands/replace.rs b/src/cli/commands/replace.rs index fc061fa..83cf578 100644 --- a/src/cli/commands/replace.rs +++ b/src/cli/commands/replace.rs @@ -1,8 +1,8 @@ -use clap::Parser; -use std::path::PathBuf; -use crate::domain::models::ReplacementStrategy; use crate::application::replace_strings::ReplaceStringsUseCase; +use crate::domain::models::ReplacementStrategy; use crate::infrastructure::*; +use clap::Parser; +use std::path::PathBuf; #[derive(Parser, Debug)] pub struct ReplaceCmd { @@ -59,7 +59,8 @@ impl ReplaceCmd { &extractor, &replacer, &import_mgr, - ).await?; + ) + .await?; println!("\nāœ“ Replacement complete!"); diff --git a/src/cli/commands/setup.rs b/src/cli/commands/setup.rs index 3e4c275..9f02cd6 100644 --- a/src/cli/commands/setup.rs +++ b/src/cli/commands/setup.rs @@ -1,9 +1,9 @@ -use clap::Parser; +use crate::cli::commands::{extract, replace, translate}; +use crate::cli::progress::ProgressReporter; +use crate::cli::wizard::{Framework, TranslationApi, Wizard}; use anyhow::Result; +use clap::Parser; use owo_colors::OwoColorize; -use crate::cli::wizard::{Wizard, Framework, TranslationApi}; -use crate::cli::progress::ProgressReporter; -use crate::cli::commands::{extract, translate, replace}; #[derive(Parser, Debug)] pub struct SetupCmd { @@ -58,7 +58,10 @@ impl SetupCmd { translate_cmd.run().await?; reporter.finish_with_success(&translate_spinner, "Translation complete"); } else { - println!("\n{}", "Step 2/4: Skipping translation (manual mode)".dimmed()); + println!( + "\n{}", + "Step 2/4: Skipping translation (manual mode)".dimmed() + ); } // Step 3: Replace strings diff --git a/src/cli/commands/translate.rs b/src/cli/commands/translate.rs index 3fccf97..25b39c8 100644 --- a/src/cli/commands/translate.rs +++ b/src/cli/commands/translate.rs @@ -1,8 +1,8 @@ -use clap::Parser; -use std::path::PathBuf; use crate::application::TranslateKeysUseCase; -use crate::infrastructure::{ConfigManager, DeepLTranslator, OpenAITranslator}; use crate::cli::presenter::Presenter; +use crate::infrastructure::{ConfigManager, DeepLTranslator, OpenAITranslator}; +use clap::Parser; +use std::path::PathBuf; #[derive(Parser, Debug)] pub struct TranslateCmd { diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 0882153..f71e7bf 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,7 +1,7 @@ pub mod commands; pub mod presenter; -pub mod wizard; pub mod progress; +pub mod wizard; use clap::Parser; use commands::Command; diff --git a/src/cli/progress.rs b/src/cli/progress.rs index 89d8752..a4d018b 100644 --- a/src/cli/progress.rs +++ b/src/cli/progress.rs @@ -1,4 +1,4 @@ -use indicatif::{ProgressBar, ProgressStyle, MultiProgress}; +use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use owo_colors::OwoColorize; use std::time::Duration; @@ -25,7 +25,7 @@ impl ProgressReporter { pb.set_style( ProgressStyle::default_spinner() .template("{spinner:.cyan} {msg}") - .expect("Invalid progress template") + .expect("Invalid progress template"), ); pb.set_message(msg.to_string()); pb.enable_steady_tick(Duration::from_millis(100)); @@ -40,7 +40,7 @@ impl ProgressReporter { ProgressStyle::default_bar() .template("{msg} [{bar:40.cyan/blue}] {pos}/{len} ({percent}%)") .expect("Invalid progress template") - .progress_chars("ā–ˆā–“ā–’ā–‘") + .progress_chars("ā–ˆā–“ā–’ā–‘"), ); pb.set_message(msg.to_string()); pb @@ -73,7 +73,10 @@ impl ExtractionSummary { #[allow(dead_code)] pub fn print(&self) { println!("\n{}", "šŸ“Š Extraction Summary:".bold()); - println!(" {} Total strings found", self.total_strings.to_string().green()); + println!( + " {} Total strings found", + self.total_strings.to_string().green() + ); println!(" ā”œā”€ {} double quotes", self.double_quotes); println!(" ā”œā”€ {} single quotes", self.single_quotes); println!(" ā”œā”€ {} template literals", self.template_literals); @@ -114,7 +117,13 @@ impl ReplacementSummary { #[allow(dead_code)] pub fn print(&self) { println!("\n{}", "šŸ“ Replacement Summary:".bold()); - println!(" {} Files modified", self.files_processed.to_string().green()); - println!(" {} Strings replaced", self.strings_replaced.to_string().green()); + println!( + " {} Files modified", + self.files_processed.to_string().green() + ); + println!( + " {} Strings replaced", + self.strings_replaced.to_string().green() + ); } } diff --git a/src/cli/wizard.rs b/src/cli/wizard.rs index d7ef34e..23b310b 100644 --- a/src/cli/wizard.rs +++ b/src/cli/wizard.rs @@ -1,7 +1,7 @@ -use dialoguer::{Select, Input, MultiSelect, Confirm}; +use anyhow::Result; +use dialoguer::{Confirm, Input, MultiSelect, Select}; use owo_colors::OwoColorize; use std::path::PathBuf; -use anyhow::Result; pub struct WizardConfig { pub framework: Framework, @@ -75,7 +75,10 @@ impl Wizard { } fn print_welcome() { - println!("\n{}\n", "šŸ§™ Langsmith Interactive Setup".bold().underline()); + println!( + "\n{}\n", + "šŸ§™ Langsmith Interactive Setup".bold().underline() + ); println!("This wizard will guide you through setting up i18n for your project.\n"); } diff --git a/src/domain/models.rs b/src/domain/models.rs index 0362d7b..f5dab47 100644 --- a/src/domain/models.rs +++ b/src/domain/models.rs @@ -1,13 +1,13 @@ -use std::collections::HashMap; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; /// Represents a translatable string extracted from code #[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct TranslationKey { - pub id: String, // e.g., "button_login" - pub source: String, // Original string: "Login" - pub file_path: String, // Where it was found + pub id: String, // e.g., "button_login" + pub source: String, // Original string: "Login" + pub file_path: String, // Where it was found pub line: usize, } @@ -41,6 +41,7 @@ impl LanguageFile { /// Supported file types for extraction #[allow(dead_code)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(clippy::upper_case_acronyms)] pub enum FileType { JavaScript, TypeScript, @@ -84,11 +85,11 @@ pub struct ExtractionConfig { #[allow(dead_code)] #[derive(Debug, Clone)] pub struct TranslationKeyWithPosition { - pub id: String, // e.g., "button_login" - pub source: String, // Original string: "Login" - pub file_path: String, // Where it was found + pub id: String, // e.g., "button_login" + pub source: String, // Original string: "Login" + pub file_path: String, // Where it was found pub line: usize, - pub start_byte: usize, // Position in file (bytes) + pub start_byte: usize, // Position in file (bytes) pub end_byte: usize, pub quote_type: QuoteType, } @@ -106,9 +107,9 @@ pub enum QuoteType { /// Strategy for code replacement (how to generate translation calls) #[derive(Debug, Clone)] pub enum ReplacementStrategy { - ReactI18n, // {t("key")} with react-i18next - VueI18n, // {{ $t('key') }} with vue-i18n - Generic, // t("key") with generic import + ReactI18n, // {t("key")} with react-i18next + VueI18n, // {{ $t('key') }} with vue-i18n + Generic, // t("key") with generic import } impl ReplacementStrategy { @@ -118,7 +119,10 @@ impl ReplacementStrategy { "react-i18n" => Ok(Self::ReactI18n), "vue-i18n" => Ok(Self::VueI18n), "generic" => Ok(Self::Generic), - _ => Err(anyhow::anyhow!("Unknown strategy: {}. Supported: react-i18n, vue-i18n, generic", s)), + _ => Err(anyhow::anyhow!( + "Unknown strategy: {}. Supported: react-i18n, vue-i18n, generic", + s + )), } } @@ -136,7 +140,7 @@ impl ReplacementStrategy { match self { Self::ReactI18n => { if in_jsx { - format!("{{t(\"{}\")}}", key) + format!("{{t(\"{}\")}}", key) } else { format!("t(\"{}\")", key) } diff --git a/src/domain/ports.rs b/src/domain/ports.rs index f8643fb..128e4bb 100644 --- a/src/domain/ports.rs +++ b/src/domain/ports.rs @@ -1,4 +1,6 @@ -use crate::domain::models::{TranslationKey, LanguageFile, FileType, TranslationKeyWithPosition, ReplacementStrategy}; +use crate::domain::models::{ + FileType, LanguageFile, ReplacementStrategy, TranslationKey, TranslationKeyWithPosition, +}; use async_trait::async_trait; use std::path::Path; @@ -7,7 +9,11 @@ use std::path::Path; #[allow(unused)] pub trait StringExtractor: Send + Sync { /// Extract all translatable strings from a file - async fn extract(&self, path: &Path, file_type: FileType) -> anyhow::Result>; + async fn extract( + &self, + path: &Path, + file_type: FileType, + ) -> anyhow::Result>; } /// Port: Responsible for writing translation files @@ -15,7 +21,8 @@ pub trait StringExtractor: Send + Sync { #[allow(unused)] pub trait FileWriter: Send + Sync { /// Write a language file to disk - async fn write_language_file(&self, path: &Path, language: &LanguageFile) -> anyhow::Result<()>; + async fn write_language_file(&self, path: &Path, language: &LanguageFile) + -> anyhow::Result<()>; /// Read a language file from disk async fn read_language_file(&self, path: &Path) -> anyhow::Result; diff --git a/src/infrastructure/code_replacer/mod.rs b/src/infrastructure/code_replacer/mod.rs index 3c12a26..6a97df7 100644 --- a/src/infrastructure/code_replacer/mod.rs +++ b/src/infrastructure/code_replacer/mod.rs @@ -1,5 +1,5 @@ -pub mod regex_replacer; pub mod import_manager; +pub mod regex_replacer; -pub use regex_replacer::RegexReplacer; pub use import_manager::SimpleImportManager; +pub use regex_replacer::RegexReplacer; diff --git a/src/infrastructure/code_replacer/regex_replacer.rs b/src/infrastructure/code_replacer/regex_replacer.rs index fb3a81a..fcba625 100644 --- a/src/infrastructure/code_replacer/regex_replacer.rs +++ b/src/infrastructure/code_replacer/regex_replacer.rs @@ -1,4 +1,4 @@ -use crate::domain::models::{TranslationKeyWithPosition, ReplacementStrategy}; +use crate::domain::models::{ReplacementStrategy, TranslationKeyWithPosition}; use crate::domain::ports::CodeReplacer; use async_trait::async_trait; use std::path::Path; diff --git a/src/infrastructure/config.rs b/src/infrastructure/config.rs index 62c1d30..8235b35 100644 --- a/src/infrastructure/config.rs +++ b/src/infrastructure/config.rs @@ -42,10 +42,7 @@ impl ConfigManager { /// 1. CLI flag (highest) /// 2. Environment variable /// 3. Error (lowest) - pub fn get_api_config( - provider: &str, - cli_api_key: Option<&str>, - ) -> anyhow::Result { + pub fn get_api_config(provider: &str, cli_api_key: Option<&str>) -> anyhow::Result { let provider = ApiProvider::from_str(provider)?; // Priority 1: CLI flag @@ -88,11 +85,11 @@ mod tests { #[test] fn test_provider_from_str() { assert_eq!(ApiProvider::from_str("deepl").unwrap(), ApiProvider::DeepL); - assert_eq!(ApiProvider::from_str("openai").unwrap(), ApiProvider::OpenAI); assert_eq!( - ApiProvider::from_str("DEEPL").unwrap(), - ApiProvider::DeepL + ApiProvider::from_str("openai").unwrap(), + ApiProvider::OpenAI ); + assert_eq!(ApiProvider::from_str("DEEPL").unwrap(), ApiProvider::DeepL); assert!(ApiProvider::from_str("invalid").is_err()); } diff --git a/src/infrastructure/file_system.rs b/src/infrastructure/file_system.rs index 188d70a..432f225 100644 --- a/src/infrastructure/file_system.rs +++ b/src/infrastructure/file_system.rs @@ -1,16 +1,20 @@ -use crate::domain::ports::{FileWriter, FileScanner}; -use crate::domain::models::{LanguageFile, FileType}; +use crate::domain::models::{FileType, LanguageFile}; +use crate::domain::ports::{FileScanner, FileWriter}; use async_trait::async_trait; use std::path::Path; -use walkdir::WalkDir; use tokio::fs; +use walkdir::WalkDir; #[allow(dead_code)] pub struct FileSystemWriter; #[async_trait] impl FileWriter for FileSystemWriter { - async fn write_language_file(&self, path: &Path, language: &LanguageFile) -> anyhow::Result<()> { + async fn write_language_file( + &self, + path: &Path, + language: &LanguageFile, + ) -> anyhow::Result<()> { // Create parent directories if needed if let Some(parent) = path.parent() { fs::create_dir_all(parent).await?; @@ -43,12 +47,12 @@ impl FileScanner for FileSystemScanner { .filter_map(|e| e.ok()) .filter(|e| e.file_type().is_file()) { - if let Some(ext) = entry.path().extension() { - if let Some(ext_str) = ext.to_str() { - let file_type = FileType::from_extension(ext_str); - if file_type.is_supported() { - files.push((entry.path().to_path_buf(), file_type)); - } + if let Some(ext) = entry.path().extension() + && let Some(ext_str) = ext.to_str() + { + let file_type = FileType::from_extension(ext_str); + if file_type.is_supported() { + files.push((entry.path().to_path_buf(), file_type)); } } } diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs index c54812d..e4f6c2a 100644 --- a/src/infrastructure/mod.rs +++ b/src/infrastructure/mod.rs @@ -1,11 +1,11 @@ -pub mod string_extractor; +pub mod code_replacer; +pub mod config; pub mod file_system; +pub mod string_extractor; pub mod translators; -pub mod config; -pub mod code_replacer; -pub use file_system::{FileSystemWriter, FileSystemScanner}; +pub use code_replacer::{RegexReplacer, SimpleImportManager}; +pub use config::{ApiProvider, ConfigManager}; +pub use file_system::{FileSystemScanner, FileSystemWriter}; pub use string_extractor::SwcStringExtractor; pub use translators::{DeepLTranslator, OpenAITranslator}; -pub use config::{ApiProvider, ConfigManager}; -pub use code_replacer::{RegexReplacer, SimpleImportManager}; diff --git a/src/infrastructure/string_extractor/js_extractor.rs b/src/infrastructure/string_extractor/js_extractor.rs index 72efb21..c6db091 100644 --- a/src/infrastructure/string_extractor/js_extractor.rs +++ b/src/infrastructure/string_extractor/js_extractor.rs @@ -1,15 +1,19 @@ +use crate::domain::models::{FileType, QuoteType, TranslationKey, TranslationKeyWithPosition}; use crate::domain::ports::StringExtractor; -use crate::domain::models::{TranslationKey, FileType, TranslationKeyWithPosition, QuoteType}; use async_trait::async_trait; -use std::path::Path; use regex::Regex; +use std::path::Path; #[allow(dead_code)] pub struct SwcStringExtractor; #[async_trait] impl StringExtractor for SwcStringExtractor { - async fn extract(&self, path: &Path, file_type: FileType) -> anyhow::Result> { + async fn extract( + &self, + path: &Path, + file_type: FileType, + ) -> anyhow::Result> { let keys_with_pos = self.extract_with_positions(path, file_type).await?; // Convert from TranslationKeyWithPosition to TranslationKey @@ -44,23 +48,26 @@ impl SwcStringExtractor { if let Ok(re) = Regex::new(r#""([^"\\]|\\.)*""#) { for cap in re.find_iter(&content) { let match_range = cap.range(); - if let Some(text) = cap.as_str().strip_prefix('"').and_then(|s| s.strip_suffix('"')) { - if self.should_extract(text, &excluded) { - let key = format_key(text); - if !seen.contains(&key) { - let line = content[..match_range.start].lines().count(); - - keys.push(TranslationKeyWithPosition { - id: key.clone(), - source: text.to_string(), - file_path: path.to_string_lossy().to_string(), - line, - start_byte: match_range.start, - end_byte: match_range.end, - quote_type: QuoteType::Double, - }); - seen.insert(key); - } + if let Some(text) = cap + .as_str() + .strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + && self.should_extract(text, &excluded) + { + let key = format_key(text); + if !seen.contains(&key) { + let line = content[..match_range.start].lines().count(); + + keys.push(TranslationKeyWithPosition { + id: key.clone(), + source: text.to_string(), + file_path: path.to_string_lossy().to_string(), + line, + start_byte: match_range.start, + end_byte: match_range.end, + quote_type: QuoteType::Double, + }); + seen.insert(key); } } } @@ -70,23 +77,26 @@ impl SwcStringExtractor { if let Ok(re) = Regex::new(r"'([^'\\]|\\.)*'") { for cap in re.find_iter(&content) { let match_range = cap.range(); - if let Some(text) = cap.as_str().strip_prefix('\'').and_then(|s| s.strip_suffix('\'')) { - if self.should_extract(text, &excluded) { - let key = format_key(text); - if !seen.contains(&key) { - let line = content[..match_range.start].lines().count(); - - keys.push(TranslationKeyWithPosition { - id: key.clone(), - source: text.to_string(), - file_path: path.to_string_lossy().to_string(), - line, - start_byte: match_range.start, - end_byte: match_range.end, - quote_type: QuoteType::Single, - }); - seen.insert(key); - } + if let Some(text) = cap + .as_str() + .strip_prefix('\'') + .and_then(|s| s.strip_suffix('\'')) + && self.should_extract(text, &excluded) + { + let key = format_key(text); + if !seen.contains(&key) { + let line = content[..match_range.start].lines().count(); + + keys.push(TranslationKeyWithPosition { + id: key.clone(), + source: text.to_string(), + file_path: path.to_string_lossy().to_string(), + line, + start_byte: match_range.start, + end_byte: match_range.end, + quote_type: QuoteType::Single, + }); + seen.insert(key); } } } @@ -96,26 +106,27 @@ impl SwcStringExtractor { if let Ok(re) = Regex::new(r"`([^\`\\]|\\.)*`") { for cap in re.find_iter(&content) { let match_range = cap.range(); - if let Some(text) = cap.as_str().strip_prefix('`').and_then(|s| s.strip_suffix('`')) { - // Skip template literals with expressions: ${...} - if !text.contains("${") { - if self.should_extract(text, &excluded) { - let key = format_key(text); - if !seen.contains(&key) { - let line = content[..match_range.start].lines().count(); - - keys.push(TranslationKeyWithPosition { - id: key.clone(), - source: text.to_string(), - file_path: path.to_string_lossy().to_string(), - line, - start_byte: match_range.start, - end_byte: match_range.end, - quote_type: QuoteType::Template, - }); - seen.insert(key); - } - } + if let Some(text) = cap + .as_str() + .strip_prefix('`') + .and_then(|s| s.strip_suffix('`')) + && !text.contains("${") + && self.should_extract(text, &excluded) + { + let key = format_key(text); + if !seen.contains(&key) { + let line = content[..match_range.start].lines().count(); + + keys.push(TranslationKeyWithPosition { + id: key.clone(), + source: text.to_string(), + file_path: path.to_string_lossy().to_string(), + line, + start_byte: match_range.start, + end_byte: match_range.end, + quote_type: QuoteType::Template, + }); + seen.insert(key); } } } @@ -126,31 +137,33 @@ impl SwcStringExtractor { for cap in re.find_iter(&content) { let match_str = cap.as_str(); // Extract text between > and ').and_then(|s| s.strip_suffix("') + .and_then(|s| s.strip_suffix(" 10 { - format!("{}...{}", &self.api_key[..5], &self.api_key[self.api_key.len()-5..]) + format!( + "{}...{}", + &self.api_key[..5], + &self.api_key[self.api_key.len() - 5..] + ) } else { "***".to_string() }; diff --git a/src/main.rs b/src/main.rs index d690710..5d8a07f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,10 @@ +mod application; mod cli; mod domain; -mod application; mod infrastructure; -use cli::Cli; use clap::Parser; +use cli::Cli; #[tokio::main] async fn main() -> anyhow::Result<()> {