Skip to main content

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        /// Build as a library instead of an executable.
165        /// Generates `lib.rs` with `crate-type = ["cdylib"]` instead of a binary.
166        #[arg(long)]
167        lib: bool,
168
169        /// Target triple for cross-compilation.
170        /// Use "wasm" as shorthand for "wasm32-unknown-unknown".
171        #[arg(long)]
172        target: Option<String>,
173    },
174
175    /// Run Z3 static verification without building.
176    ///
177    /// Performs formal verification of logical constraints in the project
178    /// using the Z3 SMT solver. This catches logical errors that would be
179    /// impossible to detect through testing alone.
180    ///
181    /// Requires a Pro+ license.
182    ///
183    /// # Example
184    ///
185    /// ```bash
186    /// largo verify --license sub_xxxxx
187    /// # Or with environment variable:
188    /// export LOGOS_LICENSE=sub_xxxxx
189    /// largo verify
190    /// ```
191    Verify {
192        /// License key for verification.
193        /// Can also be set via the `LOGOS_LICENSE` environment variable.
194        #[arg(long)]
195        license: Option<String>,
196    },
197
198    /// Build and run the current project.
199    ///
200    /// Equivalent to `largo build` followed by executing the resulting binary.
201    /// The exit code of the built program is propagated.
202    ///
203    /// # Example
204    ///
205    /// ```bash
206    /// largo run              # Debug mode
207    /// largo run --release    # Release mode
208    /// ```
209    Run {
210        /// Build with optimizations enabled.
211        #[arg(long, short)]
212        release: bool,
213    },
214
215    /// Check the project for errors without producing a binary.
216    ///
217    /// Parses and type-checks the LOGOS source without invoking the full
218    /// build pipeline. Useful for quick validation during development.
219    ///
220    /// # Example
221    ///
222    /// ```bash
223    /// largo check
224    /// ```
225    Check,
226
227    /// Publish the package to the LOGOS registry.
228    ///
229    /// Packages the project as a tarball and uploads it to the specified
230    /// registry. Requires authentication via `largo login`.
231    ///
232    /// # Pre-flight Checks
233    ///
234    /// Before publishing, the command verifies:
235    /// - The entry point exists
236    /// - No uncommitted git changes (unless `--allow-dirty`)
237    /// - Valid authentication token
238    ///
239    /// # Example
240    ///
241    /// ```bash
242    /// largo publish              # Publish to default registry
243    /// largo publish --dry-run    # Validate without uploading
244    /// ```
245    Publish {
246        /// Registry URL. Defaults to `registry.logicaffeine.com`.
247        #[arg(long)]
248        registry: Option<String>,
249
250        /// Perform all validation without actually uploading.
251        /// Useful for testing the publish process.
252        #[arg(long)]
253        dry_run: bool,
254
255        /// Allow publishing with uncommitted git changes.
256        /// By default, publishing requires a clean working directory.
257        #[arg(long)]
258        allow_dirty: bool,
259    },
260
261    /// Authenticate with the package registry.
262    ///
263    /// Stores an API token for the specified registry. The token is saved
264    /// in `~/.config/logos/credentials.toml` with restricted permissions.
265    ///
266    /// # Token Acquisition
267    ///
268    /// Tokens can be obtained from the registry's web interface:
269    /// 1. Visit `{registry}/auth/github` to authenticate
270    /// 2. Generate an API token from your profile
271    /// 3. Provide it via `--token` or interactive prompt
272    ///
273    /// # Example
274    ///
275    /// ```bash
276    /// largo login                       # Interactive prompt
277    /// largo login --token tok_xxxxx     # Non-interactive
278    /// ```
279    Login {
280        /// Registry URL. Defaults to `registry.logicaffeine.com`.
281        #[arg(long)]
282        registry: Option<String>,
283
284        /// API token. If omitted, prompts for input on stdin.
285        #[arg(long)]
286        token: Option<String>,
287    },
288
289    /// Remove stored credentials for a registry.
290    ///
291    /// Deletes the authentication token from the local credentials file.
292    ///
293    /// # Example
294    ///
295    /// ```bash
296    /// largo logout
297    /// ```
298    Logout {
299        /// Registry URL. Defaults to `registry.logicaffeine.com`.
300        #[arg(long)]
301        registry: Option<String>,
302    },
303}
304
305/// Parse CLI arguments and execute the corresponding command.
306///
307/// This is the main entry point for the `largo` CLI. It parses command-line
308/// arguments using [`clap`], then dispatches to the appropriate handler
309/// function based on the subcommand.
310///
311/// # Errors
312///
313/// Returns an error if:
314/// - The project structure is invalid (missing `Largo.toml`)
315/// - File system operations fail
316/// - Build or compilation fails
317/// - Registry operations fail (authentication, network, etc.)
318///
319/// # Example
320///
321/// ```no_run
322/// use logicaffeine_cli::cli::run_cli;
323///
324/// fn main() {
325///     if let Err(e) = run_cli() {
326///         eprintln!("Error: {}", e);
327///         std::process::exit(1);
328///     }
329/// }
330/// ```
331pub fn run_cli() -> Result<(), Box<dyn std::error::Error>> {
332    let cli = Cli::parse();
333
334    match cli.command {
335        Commands::New { name } => cmd_new(&name),
336        Commands::Init { name } => cmd_init(name.as_deref()),
337        Commands::Build { release, verify, license, lib, target } => cmd_build(release, verify, license, lib, target),
338        Commands::Run { release } => cmd_run(release),
339        Commands::Check => cmd_check(),
340        Commands::Verify { license } => cmd_verify(license),
341        Commands::Publish { registry, dry_run, allow_dirty } => {
342            cmd_publish(registry.as_deref(), dry_run, allow_dirty)
343        }
344        Commands::Login { registry, token } => cmd_login(registry.as_deref(), token),
345        Commands::Logout { registry } => cmd_logout(registry.as_deref()),
346    }
347}
348
349fn cmd_new(name: &str) -> Result<(), Box<dyn std::error::Error>> {
350    let project_dir = PathBuf::from(name);
351
352    if project_dir.exists() {
353        return Err(format!("Directory '{}' already exists", project_dir.display()).into());
354    }
355
356    // Create project structure
357    fs::create_dir_all(&project_dir)?;
358    fs::create_dir_all(project_dir.join("src"))?;
359
360    // Write Largo.toml
361    let manifest = Manifest::new(name);
362    fs::write(project_dir.join("Largo.toml"), manifest.to_toml()?)?;
363
364    // Write src/main.lg
365    let main_lg = r#"# Main
366
367A simple LOGOS program.
368
369## Main
370
371Show "Hello, world!".
372"#;
373    fs::write(project_dir.join("src/main.lg"), main_lg)?;
374
375    // Write .gitignore
376    fs::write(project_dir.join(".gitignore"), "/target\n")?;
377
378    println!("Created LOGOS project '{}'", name);
379    println!("  cd {}", project_dir.display());
380    println!("  largo run");
381
382    Ok(())
383}
384
385fn cmd_init(name: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
386    let current_dir = env::current_dir()?;
387    let project_name = name
388        .map(String::from)
389        .or_else(|| {
390            current_dir
391                .file_name()
392                .and_then(|n| n.to_str())
393                .map(String::from)
394        })
395        .unwrap_or_else(|| "project".to_string());
396
397    if current_dir.join("Largo.toml").exists() {
398        return Err("Largo.toml already exists".into());
399    }
400
401    // Create src directory if needed
402    fs::create_dir_all(current_dir.join("src"))?;
403
404    // Write Largo.toml
405    let manifest = Manifest::new(&project_name);
406    fs::write(current_dir.join("Largo.toml"), manifest.to_toml()?)?;
407
408    // Write src/main.lg if it doesn't exist
409    let main_path = current_dir.join("src/main.lg");
410    if !main_path.exists() {
411        let main_lg = r#"# Main
412
413A simple LOGOS program.
414
415## Main
416
417Show "Hello, world!".
418"#;
419        fs::write(main_path, main_lg)?;
420    }
421
422    println!("Initialized LOGOS project '{}'", project_name);
423
424    Ok(())
425}
426
427fn cmd_build(
428    release: bool,
429    verify: bool,
430    license: Option<String>,
431    lib: bool,
432    target: Option<String>,
433) -> Result<(), Box<dyn std::error::Error>> {
434    let current_dir = env::current_dir()?;
435    let project_root =
436        find_project_root(&current_dir).ok_or("Not in a LOGOS project (Largo.toml not found)")?;
437
438    // Run verification if requested
439    if verify {
440        run_verification(&project_root, license.as_deref())?;
441    }
442
443    let config = BuildConfig {
444        project_dir: project_root,
445        release,
446        lib_mode: lib,
447        target,
448    };
449
450    let result = build::build(config)?;
451
452    let mode = if release { "release" } else { "debug" };
453    println!("Built {} [{}]", result.binary_path.display(), mode);
454
455    Ok(())
456}
457
458fn cmd_verify(license: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
459    let current_dir = env::current_dir()?;
460    let project_root =
461        find_project_root(&current_dir).ok_or("Not in a LOGOS project (Largo.toml not found)")?;
462
463    run_verification(&project_root, license.as_deref())?;
464    println!("Verification passed");
465    Ok(())
466}
467
468#[cfg(feature = "verification")]
469fn run_verification(
470    project_root: &std::path::Path,
471    license: Option<&str>,
472) -> Result<(), Box<dyn std::error::Error>> {
473    use logicaffeine_verify::{LicenseValidator, Verifier};
474
475    // Get license key from argument or environment
476    let license_key = license
477        .map(String::from)
478        .or_else(|| env::var("LOGOS_LICENSE").ok());
479
480    let license_key = license_key.ok_or(
481        "Verification requires a license key.\n\
482         Use --license <key> or set LOGOS_LICENSE environment variable.\n\
483         Get a license at https://logicaffeine.com/pricing",
484    )?;
485
486    // Validate license
487    println!("Validating license...");
488    let validator = LicenseValidator::new();
489    let plan = validator.validate(&license_key)?;
490    println!("License valid ({})", plan);
491
492    // Load and parse the project
493    let manifest = Manifest::load(project_root)?;
494    let entry_path = project_root.join(&manifest.package.entry);
495    let source = fs::read_to_string(&entry_path)?;
496
497    // For now, just verify that Z3 works
498    // TODO: Implement full AST encoding in Phase 2
499    println!("Running Z3 verification...");
500    let verifier = Verifier::new();
501
502    // Basic smoke test - verify that true is valid
503    verifier.check_bool(true)?;
504
505    Ok(())
506}
507
508#[cfg(not(feature = "verification"))]
509fn run_verification(
510    _project_root: &std::path::Path,
511    _license: Option<&str>,
512) -> Result<(), Box<dyn std::error::Error>> {
513    Err("Verification requires the 'verification' feature.\n\
514         Rebuild with: cargo build --features verification"
515        .into())
516}
517
518fn cmd_run(release: bool) -> Result<(), Box<dyn std::error::Error>> {
519    let current_dir = env::current_dir()?;
520    let project_root =
521        find_project_root(&current_dir).ok_or("Not in a LOGOS project (Largo.toml not found)")?;
522
523    let config = BuildConfig {
524        project_dir: project_root,
525        release,
526        lib_mode: false,
527        target: None,
528    };
529
530    let result = build::build(config)?;
531    let exit_code = build::run(&result)?;
532
533    if exit_code != 0 {
534        std::process::exit(exit_code);
535    }
536
537    Ok(())
538}
539
540fn cmd_check() -> Result<(), Box<dyn std::error::Error>> {
541    let current_dir = env::current_dir()?;
542    let project_root =
543        find_project_root(&current_dir).ok_or("Not in a LOGOS project (Largo.toml not found)")?;
544
545    let manifest = Manifest::load(&project_root)?;
546    let entry_path = project_root.join(&manifest.package.entry);
547
548    // Just compile to Rust without building (discard output, only care about success)
549    let _ = compile_project(&entry_path)?;
550
551    println!("Check passed");
552    Ok(())
553}
554
555// ============================================================
556// Phase 39: Registry Commands
557// ============================================================
558
559fn cmd_publish(
560    registry: Option<&str>,
561    dry_run: bool,
562    allow_dirty: bool,
563) -> Result<(), Box<dyn std::error::Error>> {
564    let current_dir = env::current_dir()?;
565    let project_root =
566        find_project_root(&current_dir).ok_or("Not in a LOGOS project (Largo.toml not found)")?;
567
568    // Load manifest
569    let manifest = Manifest::load(&project_root)?;
570    let name = &manifest.package.name;
571    let version = &manifest.package.version;
572
573    println!("Packaging {} v{}", name, version);
574
575    // Determine registry URL
576    let registry_url = registry.unwrap_or(RegistryClient::default_url());
577
578    // Get authentication token
579    let token = get_token(registry_url).ok_or_else(|| {
580        format!(
581            "No authentication token found for {}.\n\
582             Run 'largo login' or set LOGOS_TOKEN environment variable.",
583            registry_url
584        )
585    })?;
586
587    // Verify the package
588    let entry_path = project_root.join(&manifest.package.entry);
589    if !entry_path.exists() {
590        return Err(format!(
591            "Entry point '{}' not found",
592            manifest.package.entry
593        ).into());
594    }
595
596    // Check for uncommitted changes
597    if !allow_dirty && is_git_dirty(&project_root) {
598        return Err(
599            "Working directory has uncommitted changes.\n\
600             Use --allow-dirty to publish anyway.".into()
601        );
602    }
603
604    // Create tarball
605    println!("Creating package tarball...");
606    let tarball = create_tarball(&project_root)?;
607    println!("  Package size: {} bytes", tarball.len());
608
609    // Read README if present
610    let readme = project_root.join("README.md");
611    let readme_content = if readme.exists() {
612        fs::read_to_string(&readme).ok()
613    } else {
614        None
615    };
616
617    // Build metadata
618    let metadata = PublishMetadata {
619        name: name.clone(),
620        version: version.clone(),
621        description: manifest.package.description.clone(),
622        repository: None, // Could add to manifest later
623        homepage: None,
624        license: None,
625        keywords: vec![],
626        entry_point: manifest.package.entry.clone(),
627        dependencies: manifest
628            .dependencies
629            .iter()
630            .map(|(k, v)| (k.clone(), v.to_string()))
631            .collect(),
632        readme: readme_content,
633    };
634
635    if dry_run {
636        println!("\n[dry-run] Would publish to {}", registry_url);
637        println!("[dry-run] Package validated successfully");
638        return Ok(());
639    }
640
641    // Upload to registry
642    println!("Uploading to {}...", registry_url);
643    let client = RegistryClient::new(registry_url, &token);
644    let result = client.publish(name, version, &tarball, &metadata)?;
645
646    println!(
647        "\nPublished {} v{} to {}",
648        result.package, result.version, registry_url
649    );
650    println!("  SHA256: {}", result.sha256);
651
652    Ok(())
653}
654
655fn cmd_login(
656    registry: Option<&str>,
657    token: Option<String>,
658) -> Result<(), Box<dyn std::error::Error>> {
659    let registry_url = registry.unwrap_or(RegistryClient::default_url());
660
661    // Get token from argument or stdin
662    let token = match token {
663        Some(t) => t,
664        None => {
665            println!("To get a token, visit: {}/auth/github", registry_url);
666            println!("Then generate an API token from your profile.");
667            println!();
668            print!("Enter token for {}: ", registry_url);
669            io::stdout().flush()?;
670
671            let mut line = String::new();
672            io::stdin().read_line(&mut line)?;
673            line.trim().to_string()
674        }
675    };
676
677    if token.is_empty() {
678        return Err("Token cannot be empty".into());
679    }
680
681    // Validate token with registry
682    println!("Validating token...");
683    let client = RegistryClient::new(registry_url, &token);
684    let user_info = client.validate_token()?;
685
686    // Save to credentials file
687    let mut creds = Credentials::load().unwrap_or_default();
688    creds.set_token(registry_url, &token);
689    creds.save()?;
690
691    println!("Logged in as {} to {}", user_info.login, registry_url);
692
693    Ok(())
694}
695
696fn cmd_logout(registry: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
697    let registry_url = registry.unwrap_or(RegistryClient::default_url());
698
699    let mut creds = Credentials::load().unwrap_or_default();
700
701    if creds.get_token(registry_url).is_none() {
702        println!("Not logged in to {}", registry_url);
703        return Ok(());
704    }
705
706    creds.remove_token(registry_url);
707    creds.save()?;
708
709    println!("Logged out from {}", registry_url);
710
711    Ok(())
712}