logicaffeine_cli/
cli.rs

1//! Phase 37/39: LOGOS CLI (largo)
2//!
3//! Command-line interface for the LOGOS build system and package registry.
4//!
5//! This module provides the command-line argument parsing and dispatch logic
6//! for the `largo` CLI tool. It handles all user-facing commands including
7//! project scaffolding, building, running, and package registry operations.
8//!
9//! # Architecture
10//!
11//! The CLI is built on [`clap`] for argument parsing with derive macros.
12//! Each command variant in [`Commands`] maps to a handler function that
13//! performs the actual work.
14//!
15//! # Examples
16//!
17//! ```bash
18//! # Create a new project
19//! largo new my_project
20//!
21//! # Build and run
22//! cd my_project
23//! largo run
24//!
25//! # Publish to registry
26//! largo login
27//! largo publish
28//! ```
29
30use clap::{Parser, Subcommand};
31use std::env;
32use std::fs;
33use std::io::{self, Write};
34use std::path::PathBuf;
35
36use crate::compile::compile_project;
37use crate::project::build::{self, find_project_root, BuildConfig};
38use crate::project::manifest::Manifest;
39use crate::project::credentials::{Credentials, get_token};
40use crate::project::registry::{
41    RegistryClient, PublishMetadata, create_tarball, is_git_dirty,
42};
43
44/// Command-line interface for the LOGOS build tool.
45///
46/// The `Cli` struct is the top-level argument parser for `largo`. It delegates
47/// to the [`Commands`] enum for subcommand handling.
48///
49/// # Usage
50///
51/// Typically invoked via [`run_cli`] which parses arguments and dispatches
52/// to the appropriate handler:
53///
54/// ```no_run
55/// use logicaffeine_cli::cli::run_cli;
56///
57/// if let Err(e) = run_cli() {
58///     eprintln!("Error: {}", e);
59///     std::process::exit(1);
60/// }
61/// ```
62#[derive(Parser)]
63#[command(name = "largo")]
64#[command(about = "The LOGOS build tool", long_about = None)]
65#[command(version)]
66pub struct Cli {
67    /// The subcommand to execute.
68    #[command(subcommand)]
69    pub command: Commands,
70}
71
72/// Available CLI subcommands.
73///
74/// Each variant represents a distinct operation that `largo` can perform.
75/// Commands are grouped into three categories:
76///
77/// ## Project Management
78/// - [`New`][Commands::New] - Create a new project in a new directory
79/// - [`Init`][Commands::Init] - Initialize a project in the current directory
80///
81/// ## Build & Run
82/// - [`Build`][Commands::Build] - Compile the project
83/// - [`Run`][Commands::Run] - Build and execute
84/// - [`Check`][Commands::Check] - Type-check without building
85/// - [`Verify`][Commands::Verify] - Run Z3 static verification
86///
87/// ## Package Registry
88/// - [`Publish`][Commands::Publish] - Upload package to registry
89/// - [`Login`][Commands::Login] - Authenticate with registry
90/// - [`Logout`][Commands::Logout] - Remove stored credentials
91#[derive(Subcommand)]
92pub enum Commands {
93    /// Create a new LOGOS project in a new directory.
94    ///
95    /// Scaffolds a complete project structure including:
96    /// - `Largo.toml` manifest file
97    /// - `src/main.lg` entry point with a "Hello, world!" example
98    /// - `.gitignore` configured for LOGOS projects
99    ///
100    /// # Example
101    ///
102    /// ```bash
103    /// largo new my_project
104    /// cd my_project
105    /// largo run
106    /// ```
107    New {
108        /// The project name, used for the directory and package name.
109        name: String,
110    },
111
112    /// Initialize a LOGOS project in the current directory.
113    ///
114    /// Similar to [`New`][Commands::New] but works in an existing directory.
115    /// Creates the manifest and source structure without creating a new folder.
116    ///
117    /// # Example
118    ///
119    /// ```bash
120    /// mkdir my_project && cd my_project
121    /// largo init
122    /// ```
123    Init {
124        /// Project name. If omitted, uses the current directory name.
125        #[arg(long)]
126        name: Option<String>,
127    },
128
129    /// Build the current project.
130    ///
131    /// Compiles the LOGOS source to Rust, then invokes `cargo build` on the
132    /// generated code. The resulting binary is placed in `target/debug/` or
133    /// `target/release/` depending on the mode.
134    ///
135    /// # Verification
136    ///
137    /// When `--verify` is passed, the build process includes Z3 static
138    /// verification of logical constraints. This requires:
139    /// - A Pro+ license (via `--license` or `LOGOS_LICENSE` env var)
140    /// - The `verification` feature enabled at build time
141    ///
142    /// # Example
143    ///
144    /// ```bash
145    /// largo build              # Debug build
146    /// largo build --release    # Release build with optimizations
147    /// largo build --verify     # Build with Z3 verification
148    /// ```
149    Build {
150        /// Build with optimizations enabled.
151        #[arg(long, short)]
152        release: bool,
153
154        /// Run Z3 static verification after compilation.
155        /// Requires a Pro+ license.
156        #[arg(long)]
157        verify: bool,
158
159        /// License key for verification.
160        /// Can also be set via the `LOGOS_LICENSE` environment variable.
161        #[arg(long)]
162        license: Option<String>,
163    },
164
165    /// Run Z3 static verification without building.
166    ///
167    /// Performs formal verification of logical constraints in the project
168    /// using the Z3 SMT solver. This catches logical errors that would be
169    /// impossible to detect through testing alone.
170    ///
171    /// Requires a Pro+ license.
172    ///
173    /// # Example
174    ///
175    /// ```bash
176    /// largo verify --license sub_xxxxx
177    /// # Or with environment variable:
178    /// export LOGOS_LICENSE=sub_xxxxx
179    /// largo verify
180    /// ```
181    Verify {
182        /// License key for verification.
183        /// Can also be set via the `LOGOS_LICENSE` environment variable.
184        #[arg(long)]
185        license: Option<String>,
186    },
187
188    /// Build and run the current project.
189    ///
190    /// Equivalent to `largo build` followed by executing the resulting binary.
191    /// The exit code of the built program is propagated.
192    ///
193    /// # Example
194    ///
195    /// ```bash
196    /// largo run              # Debug mode
197    /// largo run --release    # Release mode
198    /// ```
199    Run {
200        /// Build with optimizations enabled.
201        #[arg(long, short)]
202        release: bool,
203    },
204
205    /// Check the project for errors without producing a binary.
206    ///
207    /// Parses and type-checks the LOGOS source without invoking the full
208    /// build pipeline. Useful for quick validation during development.
209    ///
210    /// # Example
211    ///
212    /// ```bash
213    /// largo check
214    /// ```
215    Check,
216
217    /// Publish the package to the LOGOS registry.
218    ///
219    /// Packages the project as a tarball and uploads it to the specified
220    /// registry. Requires authentication via `largo login`.
221    ///
222    /// # Pre-flight Checks
223    ///
224    /// Before publishing, the command verifies:
225    /// - The entry point exists
226    /// - No uncommitted git changes (unless `--allow-dirty`)
227    /// - Valid authentication token
228    ///
229    /// # Example
230    ///
231    /// ```bash
232    /// largo publish              # Publish to default registry
233    /// largo publish --dry-run    # Validate without uploading
234    /// ```
235    Publish {
236        /// Registry URL. Defaults to `registry.logicaffeine.com`.
237        #[arg(long)]
238        registry: Option<String>,
239
240        /// Perform all validation without actually uploading.
241        /// Useful for testing the publish process.
242        #[arg(long)]
243        dry_run: bool,
244
245        /// Allow publishing with uncommitted git changes.
246        /// By default, publishing requires a clean working directory.
247        #[arg(long)]
248        allow_dirty: bool,
249    },
250
251    /// Authenticate with the package registry.
252    ///
253    /// Stores an API token for the specified registry. The token is saved
254    /// in `~/.config/logos/credentials.toml` with restricted permissions.
255    ///
256    /// # Token Acquisition
257    ///
258    /// Tokens can be obtained from the registry's web interface:
259    /// 1. Visit `{registry}/auth/github` to authenticate
260    /// 2. Generate an API token from your profile
261    /// 3. Provide it via `--token` or interactive prompt
262    ///
263    /// # Example
264    ///
265    /// ```bash
266    /// largo login                       # Interactive prompt
267    /// largo login --token tok_xxxxx     # Non-interactive
268    /// ```
269    Login {
270        /// Registry URL. Defaults to `registry.logicaffeine.com`.
271        #[arg(long)]
272        registry: Option<String>,
273
274        /// API token. If omitted, prompts for input on stdin.
275        #[arg(long)]
276        token: Option<String>,
277    },
278
279    /// Remove stored credentials for a registry.
280    ///
281    /// Deletes the authentication token from the local credentials file.
282    ///
283    /// # Example
284    ///
285    /// ```bash
286    /// largo logout
287    /// ```
288    Logout {
289        /// Registry URL. Defaults to `registry.logicaffeine.com`.
290        #[arg(long)]
291        registry: Option<String>,
292    },
293}
294
295/// Parse CLI arguments and execute the corresponding command.
296///
297/// This is the main entry point for the `largo` CLI. It parses command-line
298/// arguments using [`clap`], then dispatches to the appropriate handler
299/// function based on the subcommand.
300///
301/// # Errors
302///
303/// Returns an error if:
304/// - The project structure is invalid (missing `Largo.toml`)
305/// - File system operations fail
306/// - Build or compilation fails
307/// - Registry operations fail (authentication, network, etc.)
308///
309/// # Example
310///
311/// ```no_run
312/// use logicaffeine_cli::cli::run_cli;
313///
314/// fn main() {
315///     if let Err(e) = run_cli() {
316///         eprintln!("Error: {}", e);
317///         std::process::exit(1);
318///     }
319/// }
320/// ```
321pub fn run_cli() -> Result<(), Box<dyn std::error::Error>> {
322    let cli = Cli::parse();
323
324    match cli.command {
325        Commands::New { name } => cmd_new(&name),
326        Commands::Init { name } => cmd_init(name.as_deref()),
327        Commands::Build { release, verify, license } => cmd_build(release, verify, license),
328        Commands::Run { release } => cmd_run(release),
329        Commands::Check => cmd_check(),
330        Commands::Verify { license } => cmd_verify(license),
331        Commands::Publish { registry, dry_run, allow_dirty } => {
332            cmd_publish(registry.as_deref(), dry_run, allow_dirty)
333        }
334        Commands::Login { registry, token } => cmd_login(registry.as_deref(), token),
335        Commands::Logout { registry } => cmd_logout(registry.as_deref()),
336    }
337}
338
339fn cmd_new(name: &str) -> Result<(), Box<dyn std::error::Error>> {
340    let project_dir = PathBuf::from(name);
341
342    if project_dir.exists() {
343        return Err(format!("Directory '{}' already exists", project_dir.display()).into());
344    }
345
346    // Create project structure
347    fs::create_dir_all(&project_dir)?;
348    fs::create_dir_all(project_dir.join("src"))?;
349
350    // Write Largo.toml
351    let manifest = Manifest::new(name);
352    fs::write(project_dir.join("Largo.toml"), manifest.to_toml()?)?;
353
354    // Write src/main.lg
355    let main_lg = r#"# Main
356
357A simple LOGOS program.
358
359## Main
360
361Show "Hello, world!".
362"#;
363    fs::write(project_dir.join("src/main.lg"), main_lg)?;
364
365    // Write .gitignore
366    fs::write(project_dir.join(".gitignore"), "/target\n")?;
367
368    println!("Created LOGOS project '{}'", name);
369    println!("  cd {}", project_dir.display());
370    println!("  largo run");
371
372    Ok(())
373}
374
375fn cmd_init(name: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
376    let current_dir = env::current_dir()?;
377    let project_name = name
378        .map(String::from)
379        .or_else(|| {
380            current_dir
381                .file_name()
382                .and_then(|n| n.to_str())
383                .map(String::from)
384        })
385        .unwrap_or_else(|| "project".to_string());
386
387    if current_dir.join("Largo.toml").exists() {
388        return Err("Largo.toml already exists".into());
389    }
390
391    // Create src directory if needed
392    fs::create_dir_all(current_dir.join("src"))?;
393
394    // Write Largo.toml
395    let manifest = Manifest::new(&project_name);
396    fs::write(current_dir.join("Largo.toml"), manifest.to_toml()?)?;
397
398    // Write src/main.lg if it doesn't exist
399    let main_path = current_dir.join("src/main.lg");
400    if !main_path.exists() {
401        let main_lg = r#"# Main
402
403A simple LOGOS program.
404
405## Main
406
407Show "Hello, world!".
408"#;
409        fs::write(main_path, main_lg)?;
410    }
411
412    println!("Initialized LOGOS project '{}'", project_name);
413
414    Ok(())
415}
416
417fn cmd_build(
418    release: bool,
419    verify: bool,
420    license: Option<String>,
421) -> Result<(), Box<dyn std::error::Error>> {
422    let current_dir = env::current_dir()?;
423    let project_root =
424        find_project_root(&current_dir).ok_or("Not in a LOGOS project (Largo.toml not found)")?;
425
426    // Run verification if requested
427    if verify {
428        run_verification(&project_root, license.as_deref())?;
429    }
430
431    let config = BuildConfig {
432        project_dir: project_root,
433        release,
434    };
435
436    let result = build::build(config)?;
437
438    let mode = if release { "release" } else { "debug" };
439    println!("Built {} [{}]", result.binary_path.display(), mode);
440
441    Ok(())
442}
443
444fn cmd_verify(license: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
445    let current_dir = env::current_dir()?;
446    let project_root =
447        find_project_root(&current_dir).ok_or("Not in a LOGOS project (Largo.toml not found)")?;
448
449    run_verification(&project_root, license.as_deref())?;
450    println!("Verification passed");
451    Ok(())
452}
453
454#[cfg(feature = "verification")]
455fn run_verification(
456    project_root: &std::path::Path,
457    license: Option<&str>,
458) -> Result<(), Box<dyn std::error::Error>> {
459    use logicaffeine_verify::{LicenseValidator, Verifier};
460
461    // Get license key from argument or environment
462    let license_key = license
463        .map(String::from)
464        .or_else(|| env::var("LOGOS_LICENSE").ok());
465
466    let license_key = license_key.ok_or(
467        "Verification requires a license key.\n\
468         Use --license <key> or set LOGOS_LICENSE environment variable.\n\
469         Get a license at https://logicaffeine.com/pricing",
470    )?;
471
472    // Validate license
473    println!("Validating license...");
474    let validator = LicenseValidator::new();
475    let plan = validator.validate(&license_key)?;
476    println!("License valid ({})", plan);
477
478    // Load and parse the project
479    let manifest = Manifest::load(project_root)?;
480    let entry_path = project_root.join(&manifest.package.entry);
481    let source = fs::read_to_string(&entry_path)?;
482
483    // For now, just verify that Z3 works
484    // TODO: Implement full AST encoding in Phase 2
485    println!("Running Z3 verification...");
486    let verifier = Verifier::new();
487
488    // Basic smoke test - verify that true is valid
489    verifier.check_bool(true)?;
490
491    Ok(())
492}
493
494#[cfg(not(feature = "verification"))]
495fn run_verification(
496    _project_root: &std::path::Path,
497    _license: Option<&str>,
498) -> Result<(), Box<dyn std::error::Error>> {
499    Err("Verification requires the 'verification' feature.\n\
500         Rebuild with: cargo build --features verification"
501        .into())
502}
503
504fn cmd_run(release: bool) -> Result<(), Box<dyn std::error::Error>> {
505    let current_dir = env::current_dir()?;
506    let project_root =
507        find_project_root(&current_dir).ok_or("Not in a LOGOS project (Largo.toml not found)")?;
508
509    let config = BuildConfig {
510        project_dir: project_root,
511        release,
512    };
513
514    let result = build::build(config)?;
515    let exit_code = build::run(&result)?;
516
517    if exit_code != 0 {
518        std::process::exit(exit_code);
519    }
520
521    Ok(())
522}
523
524fn cmd_check() -> Result<(), Box<dyn std::error::Error>> {
525    let current_dir = env::current_dir()?;
526    let project_root =
527        find_project_root(&current_dir).ok_or("Not in a LOGOS project (Largo.toml not found)")?;
528
529    let manifest = Manifest::load(&project_root)?;
530    let entry_path = project_root.join(&manifest.package.entry);
531
532    // Just compile to Rust without building
533    compile_project(&entry_path)?;
534
535    println!("Check passed");
536    Ok(())
537}
538
539// ============================================================
540// Phase 39: Registry Commands
541// ============================================================
542
543fn cmd_publish(
544    registry: Option<&str>,
545    dry_run: bool,
546    allow_dirty: bool,
547) -> Result<(), Box<dyn std::error::Error>> {
548    let current_dir = env::current_dir()?;
549    let project_root =
550        find_project_root(&current_dir).ok_or("Not in a LOGOS project (Largo.toml not found)")?;
551
552    // Load manifest
553    let manifest = Manifest::load(&project_root)?;
554    let name = &manifest.package.name;
555    let version = &manifest.package.version;
556
557    println!("Packaging {} v{}", name, version);
558
559    // Determine registry URL
560    let registry_url = registry.unwrap_or(RegistryClient::default_url());
561
562    // Get authentication token
563    let token = get_token(registry_url).ok_or_else(|| {
564        format!(
565            "No authentication token found for {}.\n\
566             Run 'largo login' or set LOGOS_TOKEN environment variable.",
567            registry_url
568        )
569    })?;
570
571    // Verify the package
572    let entry_path = project_root.join(&manifest.package.entry);
573    if !entry_path.exists() {
574        return Err(format!(
575            "Entry point '{}' not found",
576            manifest.package.entry
577        ).into());
578    }
579
580    // Check for uncommitted changes
581    if !allow_dirty && is_git_dirty(&project_root) {
582        return Err(
583            "Working directory has uncommitted changes.\n\
584             Use --allow-dirty to publish anyway.".into()
585        );
586    }
587
588    // Create tarball
589    println!("Creating package tarball...");
590    let tarball = create_tarball(&project_root)?;
591    println!("  Package size: {} bytes", tarball.len());
592
593    // Read README if present
594    let readme = project_root.join("README.md");
595    let readme_content = if readme.exists() {
596        fs::read_to_string(&readme).ok()
597    } else {
598        None
599    };
600
601    // Build metadata
602    let metadata = PublishMetadata {
603        name: name.clone(),
604        version: version.clone(),
605        description: manifest.package.description.clone(),
606        repository: None, // Could add to manifest later
607        homepage: None,
608        license: None,
609        keywords: vec![],
610        entry_point: manifest.package.entry.clone(),
611        dependencies: manifest
612            .dependencies
613            .iter()
614            .map(|(k, v)| (k.clone(), v.to_string()))
615            .collect(),
616        readme: readme_content,
617    };
618
619    if dry_run {
620        println!("\n[dry-run] Would publish to {}", registry_url);
621        println!("[dry-run] Package validated successfully");
622        return Ok(());
623    }
624
625    // Upload to registry
626    println!("Uploading to {}...", registry_url);
627    let client = RegistryClient::new(registry_url, &token);
628    let result = client.publish(name, version, &tarball, &metadata)?;
629
630    println!(
631        "\nPublished {} v{} to {}",
632        result.package, result.version, registry_url
633    );
634    println!("  SHA256: {}", result.sha256);
635
636    Ok(())
637}
638
639fn cmd_login(
640    registry: Option<&str>,
641    token: Option<String>,
642) -> Result<(), Box<dyn std::error::Error>> {
643    let registry_url = registry.unwrap_or(RegistryClient::default_url());
644
645    // Get token from argument or stdin
646    let token = match token {
647        Some(t) => t,
648        None => {
649            println!("To get a token, visit: {}/auth/github", registry_url);
650            println!("Then generate an API token from your profile.");
651            println!();
652            print!("Enter token for {}: ", registry_url);
653            io::stdout().flush()?;
654
655            let mut line = String::new();
656            io::stdin().read_line(&mut line)?;
657            line.trim().to_string()
658        }
659    };
660
661    if token.is_empty() {
662        return Err("Token cannot be empty".into());
663    }
664
665    // Validate token with registry
666    println!("Validating token...");
667    let client = RegistryClient::new(registry_url, &token);
668    let user_info = client.validate_token()?;
669
670    // Save to credentials file
671    let mut creds = Credentials::load().unwrap_or_default();
672    creds.set_token(registry_url, &token);
673    creds.save()?;
674
675    println!("Logged in as {} to {}", user_info.login, registry_url);
676
677    Ok(())
678}
679
680fn cmd_logout(registry: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
681    let registry_url = registry.unwrap_or(RegistryClient::default_url());
682
683    let mut creds = Credentials::load().unwrap_or_default();
684
685    if creds.get_token(registry_url).is_none() {
686        println!("Not logged in to {}", registry_url);
687        return Ok(());
688    }
689
690    creds.remove_token(registry_url);
691    creds.save()?;
692
693    println!("Logged out from {}", registry_url);
694
695    Ok(())
696}