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(¤t_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(¤t_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(¤t_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(¤t_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(¤t_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}