Skip to main content

logicaffeine_cli/project/
registry.rs

1//! Phase 39: Registry Client
2//!
3//! HTTP client for communicating with the LOGOS package registry.
4//!
5//! This module provides the [`RegistryClient`] for authenticated API calls to
6//! the package registry, along with supporting types for package metadata and
7//! error handling.
8//!
9//! # Architecture
10//!
11//! The registry client uses [`ureq`] for HTTP requests with Bearer token
12//! authentication. All requests are made over HTTPS to the configured registry
13//! URL (defaulting to `registry.logicaffeine.com`).
14//!
15//! # Example
16//!
17//! ```no_run
18//! use logicaffeine_cli::project::registry::{RegistryClient, PublishMetadata};
19//!
20//! let client = RegistryClient::new("https://registry.logicaffeine.com", "tok_xxx");
21//!
22//! // Validate authentication
23//! let user = client.validate_token()?;
24//! println!("Authenticated as: {}", user.login);
25//! # Ok::<(), Box<dyn std::error::Error>>(())
26//! ```
27
28use std::path::Path;
29
30const DEFAULT_REGISTRY_URL: &str = "https://registry.logicaffeine.com";
31
32/// HTTP client for the LOGOS package registry API.
33///
34/// Provides authenticated access to registry operations including:
35/// - Token validation
36/// - Package publishing
37///
38/// # Authentication
39///
40/// All API calls require a Bearer token, typically obtained via `largo login`.
41/// Tokens are validated against the registry's `/auth/me` endpoint.
42///
43/// # Example
44///
45/// ```no_run
46/// use logicaffeine_cli::project::registry::RegistryClient;
47///
48/// let client = RegistryClient::new(
49///     RegistryClient::default_url(),
50///     "tok_xxxxx"
51/// );
52///
53/// // Verify the token is valid
54/// match client.validate_token() {
55///     Ok(user) => println!("Logged in as {}", user.login),
56///     Err(e) => eprintln!("Auth failed: {}", e),
57/// }
58/// ```
59pub struct RegistryClient {
60    /// Base URL of the registry API (without trailing slash).
61    base_url: String,
62    /// Bearer token for authentication.
63    token: String,
64}
65
66impl RegistryClient {
67    /// Create a new registry client with the given URL and authentication token.
68    ///
69    /// # Arguments
70    ///
71    /// * `base_url` - The registry API base URL. Trailing slashes are stripped.
72    /// * `token` - Bearer token for authentication.
73    ///
74    /// # Example
75    ///
76    /// ```
77    /// use logicaffeine_cli::project::registry::RegistryClient;
78    ///
79    /// let client = RegistryClient::new(
80    ///     "https://registry.logicaffeine.com",
81    ///     "tok_xxxxx"
82    /// );
83    /// ```
84    pub fn new(base_url: &str, token: &str) -> Self {
85        Self {
86            base_url: base_url.trim_end_matches('/').to_string(),
87            token: token.to_string(),
88        }
89    }
90
91    /// Returns the default registry URL.
92    ///
93    /// Currently returns `https://registry.logicaffeine.com`.
94    pub fn default_url() -> &'static str {
95        DEFAULT_REGISTRY_URL
96    }
97
98    /// Validate the authentication token by querying the registry.
99    ///
100    /// Makes a request to `/auth/me` to verify the token is valid and
101    /// retrieve the associated user information.
102    ///
103    /// # Errors
104    ///
105    /// Returns [`RegistryError::Unauthorized`] if the token is invalid or expired.
106    /// Returns [`RegistryError::Network`] for connection failures.
107    pub fn validate_token(&self) -> Result<UserInfo, RegistryError> {
108        let url = format!("{}/auth/me", self.base_url);
109
110        let response = ureq::get(&url)
111            .set("Authorization", &format!("Bearer {}", self.token))
112            .call()
113            .map_err(|e| match e {
114                ureq::Error::Status(401, _) => RegistryError::Unauthorized,
115                ureq::Error::Status(403, r) => {
116                    let msg = r.into_string().unwrap_or_default();
117                    RegistryError::Forbidden(msg)
118                }
119                ureq::Error::Status(code, r) => RegistryError::Server {
120                    status: code,
121                    message: r.into_string().unwrap_or_default(),
122                },
123                e => RegistryError::Network(e.to_string()),
124            })?;
125
126        let user: UserInfo = response.into_json()
127            .map_err(|e| RegistryError::Network(e.to_string()))?;
128
129        Ok(user)
130    }
131
132    /// Publish a package to the registry.
133    ///
134    /// Uploads a package tarball with metadata to the registry's publish endpoint.
135    /// The request is sent as multipart form data.
136    ///
137    /// # Arguments
138    ///
139    /// * `name` - Package name (must match manifest)
140    /// * `version` - Semantic version string
141    /// * `tarball` - Gzipped tar archive of the package
142    /// * `metadata` - Package metadata for the registry index
143    ///
144    /// # Errors
145    ///
146    /// - [`RegistryError::Unauthorized`] - Invalid or missing token
147    /// - [`RegistryError::VersionExists`] - This version already published
148    /// - [`RegistryError::TooLarge`] - Package exceeds 10MB limit
149    /// - [`RegistryError::InvalidPackage`] - Metadata serialization failed
150    pub fn publish(
151        &self,
152        name: &str,
153        version: &str,
154        tarball: &[u8],
155        metadata: &PublishMetadata,
156    ) -> Result<PublishResult, RegistryError> {
157        use std::io::Read;
158
159        let url = format!("{}/packages/publish", self.base_url);
160
161        // Create multipart form data
162        let boundary = format!("----LargoBoundary{}", rand::random::<u64>());
163
164        let metadata_json = serde_json::to_string(metadata)
165            .map_err(|e| RegistryError::InvalidPackage(e.to_string()))?;
166
167        let mut body = Vec::new();
168
169        // Add metadata field
170        body.extend_from_slice(format!(
171            "--{}\r\nContent-Disposition: form-data; name=\"metadata\"\r\n\r\n{}\r\n",
172            boundary, metadata_json
173        ).as_bytes());
174
175        // Add tarball field
176        body.extend_from_slice(format!(
177            "--{}\r\nContent-Disposition: form-data; name=\"tarball\"; filename=\"{}-{}.tar.gz\"\r\nContent-Type: application/gzip\r\n\r\n",
178            boundary, name, version
179        ).as_bytes());
180        body.extend_from_slice(tarball);
181        body.extend_from_slice(format!("\r\n--{}--\r\n", boundary).as_bytes());
182
183        let response = ureq::post(&url)
184            .set("Authorization", &format!("Bearer {}", self.token))
185            .set("Content-Type", &format!("multipart/form-data; boundary={}", boundary))
186            .send_bytes(&body)
187            .map_err(|e| match e {
188                ureq::Error::Status(401, _) => RegistryError::Unauthorized,
189                ureq::Error::Status(403, r) => {
190                    let msg = r.into_string().unwrap_or_else(|_| "Forbidden".to_string());
191                    RegistryError::Forbidden(msg)
192                }
193                ureq::Error::Status(409, _) => RegistryError::VersionExists {
194                    name: name.to_string(),
195                    version: version.to_string(),
196                },
197                ureq::Error::Status(413, _) => RegistryError::TooLarge,
198                ureq::Error::Status(code, r) => RegistryError::Server {
199                    status: code,
200                    message: r.into_string().unwrap_or_default(),
201                },
202                e => RegistryError::Network(e.to_string()),
203            })?;
204
205        let result: PublishResult = response.into_json()
206            .map_err(|e| RegistryError::Network(e.to_string()))?;
207
208        Ok(result)
209    }
210}
211
212/// Create a gzipped tarball from a LOGOS project.
213///
214/// Packages the project for upload to the registry. The tarball includes:
215/// - `Largo.toml` (required)
216/// - `src/` directory recursively (required)
217/// - `README.md` (if present)
218/// - `LICENSE` (if present)
219///
220/// Hidden files (starting with `.`) and the `target/` directory are excluded.
221/// Only `.lg`, `.md`, `.toml`, and `.json` files are included from `src/`.
222///
223/// # Arguments
224///
225/// * `project_dir` - Root directory of the LOGOS project
226///
227/// # Errors
228///
229/// Returns [`PackageError::MissingFile`] if `Largo.toml` or `src/` is missing.
230pub fn create_tarball(project_dir: &Path) -> Result<Vec<u8>, PackageError> {
231    use flate2::write::GzEncoder;
232    use flate2::Compression;
233    use tar::Builder;
234    use std::fs::File;
235    use std::io::Write;
236
237    let mut tarball = Vec::new();
238
239    {
240        let encoder = GzEncoder::new(&mut tarball, Compression::default());
241        let mut builder = Builder::new(encoder);
242
243        // Add Largo.toml
244        let manifest_path = project_dir.join("Largo.toml");
245        if !manifest_path.exists() {
246            return Err(PackageError::MissingFile("Largo.toml".to_string()));
247        }
248        add_file_to_tar(&mut builder, project_dir, "Largo.toml")?;
249
250        // Add src/ directory recursively
251        let src_dir = project_dir.join("src");
252        if !src_dir.exists() {
253            return Err(PackageError::MissingFile("src/".to_string()));
254        }
255        add_dir_recursive(&mut builder, project_dir, "src")?;
256
257        // Add README.md if it exists
258        if project_dir.join("README.md").exists() {
259            add_file_to_tar(&mut builder, project_dir, "README.md")?;
260        }
261
262        // Add LICENSE if it exists
263        if project_dir.join("LICENSE").exists() {
264            add_file_to_tar(&mut builder, project_dir, "LICENSE")?;
265        }
266
267        builder.finish()
268            .map_err(|e| PackageError::TarError(e.to_string()))?;
269    }
270
271    Ok(tarball)
272}
273
274fn add_file_to_tar<W: std::io::Write>(
275    builder: &mut tar::Builder<W>,
276    base_dir: &Path,
277    rel_path: &str,
278) -> Result<(), PackageError> {
279    let full_path = base_dir.join(rel_path);
280    let content = std::fs::read(&full_path)
281        .map_err(|e| PackageError::Io(format!("{}: {}", rel_path, e)))?;
282
283    let mut header = tar::Header::new_gnu();
284    header.set_path(rel_path)
285        .map_err(|e| PackageError::TarError(e.to_string()))?;
286    header.set_size(content.len() as u64);
287    header.set_mode(0o644);
288    header.set_mtime(0); // Reproducible builds
289    header.set_cksum();
290
291    builder.append(&header, content.as_slice())
292        .map_err(|e| PackageError::TarError(e.to_string()))?;
293
294    Ok(())
295}
296
297fn add_dir_recursive<W: std::io::Write>(
298    builder: &mut tar::Builder<W>,
299    base_dir: &Path,
300    rel_dir: &str,
301) -> Result<(), PackageError> {
302    let full_dir = base_dir.join(rel_dir);
303
304    for entry in std::fs::read_dir(&full_dir)
305        .map_err(|e| PackageError::Io(format!("{}: {}", rel_dir, e)))?
306    {
307        let entry = entry.map_err(|e| PackageError::Io(e.to_string()))?;
308        let path = entry.path();
309        let name = entry.file_name();
310        let rel_path = format!("{}/{}", rel_dir, name.to_string_lossy());
311
312        // Skip hidden files and target directory
313        let name_str = name.to_string_lossy();
314        if name_str.starts_with('.') || name_str == "target" {
315            continue;
316        }
317
318        if path.is_dir() {
319            add_dir_recursive(builder, base_dir, &rel_path)?;
320        } else if path.is_file() {
321            // Only include .lg, .md, and common config files
322            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
323            if matches!(ext, "lg" | "md" | "toml" | "json") || name_str == "LICENSE" {
324                add_file_to_tar(builder, base_dir, &rel_path)?;
325            }
326        }
327    }
328
329    Ok(())
330}
331
332/// Check if the git working directory has uncommitted changes.
333///
334/// Runs `git status --porcelain` and returns `true` if there is any output,
335/// indicating uncommitted changes (modified, staged, or untracked files).
336///
337/// Returns `false` if:
338/// - The directory is not a git repository
339/// - Git is not available on the system
340/// - The working directory is clean
341///
342/// # Arguments
343///
344/// * `project_dir` - Directory to check (should contain `.git`)
345pub fn is_git_dirty(project_dir: &Path) -> bool {
346    use std::process::Command;
347
348    let output = Command::new("git")
349        .args(["status", "--porcelain"])
350        .current_dir(project_dir)
351        .output();
352
353    match output {
354        Ok(out) if out.status.success() => !out.stdout.is_empty(),
355        _ => false, // Not a git repo or git not available
356    }
357}
358
359// ============== Types ==============
360
361/// User information returned from the registry's `/auth/me` endpoint.
362///
363/// Contains details about the authenticated user, used to confirm
364/// successful login and display user information.
365#[derive(Debug, serde::Deserialize)]
366pub struct UserInfo {
367    /// Unique user identifier in the registry.
368    pub id: String,
369    /// GitHub username (used for login).
370    pub login: String,
371    /// Display name (may differ from login).
372    pub name: Option<String>,
373    /// Whether the user has registry admin privileges.
374    pub is_admin: bool,
375}
376
377/// Metadata submitted when publishing a package.
378///
379/// This information is stored in the registry index and displayed
380/// on the package's registry page.
381#[derive(Debug, serde::Serialize)]
382pub struct PublishMetadata {
383    /// Package name (must match `Largo.toml`).
384    pub name: String,
385    /// Semantic version string (e.g., "1.0.0").
386    pub version: String,
387    /// Short description of the package.
388    pub description: Option<String>,
389    /// URL to the source repository (e.g., GitHub).
390    pub repository: Option<String>,
391    /// URL to the project homepage or documentation.
392    pub homepage: Option<String>,
393    /// SPDX license identifier (e.g., "MIT", "Apache-2.0").
394    pub license: Option<String>,
395    /// Searchable keywords for discovery.
396    pub keywords: Vec<String>,
397    /// Relative path to the entry point file.
398    pub entry_point: String,
399    /// Map of dependency names to version requirements.
400    pub dependencies: std::collections::HashMap<String, String>,
401    /// Full README content (if `README.md` exists).
402    pub readme: Option<String>,
403}
404
405/// Response from a successful publish operation.
406///
407/// Returned by the registry after a package is successfully uploaded
408/// and indexed.
409#[derive(Debug, serde::Deserialize)]
410pub struct PublishResult {
411    /// Whether the publish succeeded.
412    pub success: bool,
413    /// The published package name.
414    pub package: String,
415    /// The published version.
416    pub version: String,
417    /// SHA-256 hash of the uploaded tarball.
418    pub sha256: String,
419    /// Size of the tarball in bytes.
420    pub size: u64,
421}
422
423// ============== Errors ==============
424
425/// Errors that can occur during registry API operations.
426///
427/// Each variant includes a user-friendly error message with guidance
428/// on how to resolve the issue.
429#[derive(Debug)]
430pub enum RegistryError {
431    /// No authentication token was provided or found.
432    NoToken,
433    /// The provided token is invalid or expired (HTTP 401).
434    Unauthorized,
435    /// The server rejected the request (HTTP 403).
436    Forbidden(String),
437    /// The package version already exists in the registry (HTTP 409).
438    VersionExists {
439        /// Package name.
440        name: String,
441        /// Version that already exists.
442        version: String,
443    },
444    /// The package tarball exceeds the size limit (HTTP 413).
445    TooLarge,
446    /// Network or connection error.
447    Network(String),
448    /// The server returned an unexpected error.
449    Server {
450        /// HTTP status code.
451        status: u16,
452        /// Error message from the server.
453        message: String,
454    },
455    /// The package metadata could not be serialized.
456    InvalidPackage(String),
457}
458
459impl std::fmt::Display for RegistryError {
460    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
461        match self {
462            Self::NoToken => write!(
463                f,
464                "No authentication token found.\n\
465                 Run 'largo login' or set LOGOS_TOKEN environment variable."
466            ),
467            Self::Unauthorized => write!(
468                f,
469                "Authentication failed. Your token may be invalid or expired.\n\
470                 Run 'largo login' to get a new token."
471            ),
472            Self::Forbidden(msg) => write!(f, "Access denied: {}", msg),
473            Self::VersionExists { name, version } => write!(
474                f,
475                "Version {} of package '{}' already exists.\n\
476                 Update the version in Largo.toml and try again.",
477                version, name
478            ),
479            Self::TooLarge => write!(f, "Package too large. Maximum size is 10MB."),
480            Self::Network(e) => write!(f, "Network error: {}", e),
481            Self::Server { status, message } => {
482                write!(f, "Registry returned error {}: {}", status, message)
483            }
484            Self::InvalidPackage(e) => write!(f, "Invalid package: {}", e),
485        }
486    }
487}
488
489impl std::error::Error for RegistryError {}
490
491/// Errors that can occur when creating a package tarball.
492#[derive(Debug)]
493pub enum PackageError {
494    /// A required file is missing from the project.
495    MissingFile(String),
496    /// A file system operation failed.
497    Io(String),
498    /// The tar archive could not be created.
499    TarError(String),
500}
501
502impl std::fmt::Display for PackageError {
503    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
504        match self {
505            Self::MissingFile(name) => write!(f, "Missing required file: {}", name),
506            Self::Io(e) => write!(f, "I/O error: {}", e),
507            Self::TarError(e) => write!(f, "Failed to create tarball: {}", e),
508        }
509    }
510}
511
512impl std::error::Error for PackageError {}