diff --git a/CHANGELOG.md b/CHANGELOG.md index 597936a..0701b5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # changelog ## Unreleased + - TUI added to run all the commands interactively + - Results from commands are now send in string formats (easy for AI Agents to parse) + +## 0.1.0-alpha.1 - scaffold changed to make it more modular - expanded test coverage - deployment script updated to perform based on artist and a subscription diff --git a/Cargo.toml b/Cargo.toml index e1a3998..ad44508 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "cite-cli" authors = ["Binay Pradhan"] -version = "0.1.0-alpha.1" +version = "0.1.0-alpha.2" edition = "2024" readme = "README.md" @@ -16,7 +16,7 @@ serde_json = "1.0" serde_yaml = "0.9" toml = "1.0" tokio = { version = "1.44", features = ["full"] } -reqwest = { version = "0.12", features = ["json"], default-features = false } +reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } sha2 = "0.10" percent-encoding = "2.3" uuid = { version = "1.23", features = ["v4"] } @@ -24,11 +24,12 @@ colored = "3.0" thiserror = "2.0" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } -regex = "1.13.0" +ratatui = { version = "0.30.2", features = ["crossterm"] } [dev-dependencies] tempfile = "3.19" httpmock = "0.7" +regex = "1.13.0" [[bin]] name = "cite-cli" diff --git a/README.md b/README.md index 1608aa4..23f45de 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,11 @@ CLI tool for scaffolding, validating, building, and deploying podcast content to (MacOS/Linux only) ```bash -curl --proto '=https' --tlsv1.2 -LsSf https://github.com/VinayIN/cite-cli/releases/download/v0.1.0-alpha.1/cite-cli-installer.sh | sh +curl --proto '=https' --tlsv1.2 -LsSf https://github.com/VinayIN/cite-cli/releases/download/v0.1.0-alpha.2/cite-cli-installer.sh | sh ``` (Windows only) ```powershell -powershell -ExecutionPolicy Bypass -c "irm https://github.com/VinayIN/cite-cli/releases/download/v0.1.0-alpha.1/cite-cli-installer.ps1 | iex" +powershell -ExecutionPolicy Bypass -c "irm https://github.com/VinayIN/cite-cli/releases/download/v0.1.0-alpha.2/cite-cli-installer.ps1 | iex" ``` ### From source @@ -29,12 +29,15 @@ cargo build --release ```bash cite-cli init my-project # edit metadata.yml and add content files -cite-cli validate --path my-project +cite-cli doctor --path my-project cite-cli build --path my-project cite-cli status --path my-project cite-cli deploy --path my-project ``` +> TUI: Running `cite-cli` will open an interactive terminal UI for running the commands. +> NOTE: The editing of content files should still be done on your IDE. + ## Tests ```bash @@ -44,16 +47,18 @@ cargo test ## Commands | Command | Description | -|---|---| +|---|---|---| | `init ` | Scaffold a new project | -| `validate` | Check structure, metadata, file existence | -| `lint` | Word counts and content quality checks | +| `doctor` | Validate structure, metadata, file existence, asset formats, and config | +| `lint` | Word count checks | | `build` | Incremental build -> `build/content.json` | | `deploy` | Deploy to Supabase (full JSON to storage + table subset) | | `status` | Project health overview | -| `doctor` | Diagnose config and structure issues | | `clean` | Remove build artifacts and cache | | `rollback ` | Undo a deployment by ID | +| `login` | Authenticate with Supabase for user-scoped deploys | +| `upgrade` | Self-update to the latest GitHub release | +| `uninstall` | Remove cite-cli binary and clean shell config | All commands accept `--path ` to target a specific directory. Without `--path`, projects are auto-discovered in the current directory and subdirectories. @@ -69,6 +74,6 @@ my-project/ │ └── article1.bib ├── assets/ │ ├── audio/ # Podcast audio files -│ └── images/ # Thumbnails and cover art +│ └── image/ # Thumbnails and cover art └── build/ # Auto-Generated build output (gitignored) ``` diff --git a/docs/PRD.md b/docs/PRD.md index 991da4e..d9a5ce6 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -11,7 +11,7 @@ ## 3. Core Concepts - **Project**: A folder with `cite.toml`, `metadata.yml`, and content directories. -- **Artist**: A pre-existing Supabase user identified by UUID in `metadata.yml`. The CLI user is the content creator. +- **Artist**: A pre-existing Supabase user identified by UUID in `cite.toml` (`[project].artist_id`). The CLI user is the content creator. - **Podcast**: A content item consisting of a markdown file, optional audio, optional source URL, optional thumbnail, and optional BibTeX citation. - **Compiler Protocol**: Versioned process transforming source files into a `content.json` bundle. - **Deployment**: Full `content.json` uploaded to Supabase Storage as `{deployment_id}.json`, plus a subset written to database tables for queryability. @@ -19,18 +19,17 @@ ## 4. Metadata Model (`metadata.yml`) ```yaml -artist_id: "11111111-1111-1111-1111-111111111111" podcasts: - title: "My Podcast" file: content/my-article.md source_url: "https://example.com" category: "artificial intelligence" audio: assets/audio/episode.mp3 - thumbnail: assets/images/thumb.jpg + thumbnail: assets/image/thumb.jpg citation: content/my-article.bib ``` -- `artist_id` references an existing artist record in the database. +- `artist_id` references an existing artist record in the database (configured in `cite.toml` under `[project]`). - `podcasts` is an array of content items. Each has a title, markdown file, and optional audio/thumbnail/citation. - No slugs, no junction tables, no tier-based audio variants. @@ -39,14 +38,16 @@ podcasts: | Command | Description | |---------|-------------| | `init ` | Create project structure with starter files | -| `validate` | Check structure, metadata, file existence, asset formats | +| `doctor` | Validate structure, metadata, file existence, asset formats, and config | | `lint` | Word count and content quality checks | | `build` | Compile content → `build/content.json` with UUIDs and BibTeX timelines | -| `deploy` | Upload full bundle to storage; populate `news`, `podcasts`, `artists_news`, `timeline` tables | +| `deploy` | Upload full bundle to storage; populate `news`, `podcasts`, `artists_news`, `timeline`, `timeline_news`, `urls`, `categories`, `domains`, `metric` tables | | `status` | Project health overview | -| `doctor` | Diagnose configuration and dependency issues | | `clean` | Remove `build/` directory and cache | -| `rollback ` | Delete rows tagged with deployment_id from `podcasts`, `artists_news`, `news` | +| `rollback ` | Delete rows and storage objects associated with a deployment | +| `login` | Authenticate with Supabase and link artist account | +| `upgrade` | Self-update to the latest GitHub release | +| `uninstall` | Remove cite-cli binary and shell config references | ## 6. Build Pipeline @@ -66,29 +67,33 @@ The compiler: ``` content.json → Upload full bundle to storage as {deployment_id}.json → For each podcast: - Resolve category by name - Create url record from source_url + Verify artist exists + Resolve/create category by name + Create url record (with domain lookup) from source_url Insert news row - Auto-create artists_news junction + Create artists_news junction + Create metric row + Upload thumbnail → update news.thumbnail Upload audio → insert podcasts row Parse BibTeX timelines → insert timeline + timeline_news ``` - All rows tagged with `deployment_id` for rollback. - Dry-run mode (`--dry-run`) shows what would happen without writing. -- Service key sourced from env var `CITE_STAGING_SERVICE_KEY` or `cite.toml`. +- Service key sourced from `backend.staging_service_key` in `cite.toml`. ## 8. Project Structure ``` my-project/ ├── cite.toml # Project manifest -├── metadata.yml # Artist ID + podcast list -├── content/ # Markdown content files +├── metadata.yml # Podcast content metadata +├── .gitignore # Ignores build/ and cache +├── content/ # Markdown & BibTeX content files ├── assets/ │ ├── audio/ # MP3/WAV/M4A files -│ └── images/ # Thumbnails, cover art -└── build/ # Output (gitignored) +│ └── image/ # Thumbnails, cover art +└── build/ # Build output (gitignored) ``` ## 9. Design Principles diff --git a/src/cli.rs b/src/cli.rs index 99801f9..72980a7 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,10 +1,9 @@ use clap::{Parser, Subcommand}; -use colored::Colorize; use std::path::{Path, PathBuf}; -use tracing::debug; +use tracing::{debug, info, warn}; -use crate::error::CiteError; -use crate::{compiler, deploy, project, scaffold, uninstall, upgrade, validation}; +use crate::core::report::{CiteError, Style, styled}; +use crate::core::{compiler, deploy, doctor, project, scaffold, uninstall, upgrade}; #[derive(Parser)] #[command( @@ -14,25 +13,20 @@ use crate::{compiler, deploy, project, scaffold, uninstall, upgrade, validation} )] pub struct Cli { #[command(subcommand)] - pub command: Command, + pub command: Option, #[arg(global = true, short, long)] pub verbose: bool, } #[derive(Subcommand)] -pub enum Command { +pub enum CliCommand { /// Create a new project with recommended structure and starter files Init { name: String, #[arg(short, long)] path: Option, }, - /// Run full validation (structure, files, metadata, cross-references) - Validate { - #[arg(short, long)] - path: Option, - }, /// Run linting rules (naming, style, word counts) Lint { #[arg(short, long)] @@ -52,6 +46,15 @@ pub enum Command { #[arg(long)] dry_run: bool, }, + /// Authenticate with Supabase and store a session for user-scoped deploys + Login { + #[arg(long)] + email: Option, + #[arg(long)] + password: Option, + #[arg(short, long)] + path: Option, + }, /// Show project health, validation summary, and sync state Status { #[arg(short, long)] @@ -96,151 +99,88 @@ fn load_project(root: &Path) -> Result { fn discover_projects(path: Option) -> Vec { let root = resolve_path(path); - let mut projects = Vec::new(); - - if root.join("cite.toml").exists() { - projects.push(root.clone()); - } - - if let Ok(entries) = std::fs::read_dir(&root) { - for entry in entries.flatten() { - let p = entry.path(); - if p.is_dir() && p != root && p.join("cite.toml").exists() { - projects.push(p); - } - } - } - - projects.sort(); - projects + project::discover_projects(&root) } fn load_projects( path: Option, empty_msg: &str, -) -> Result, bool)>, CiteError> { +) -> Result>, CiteError> { let roots = discover_projects(path); if roots.is_empty() { - eprintln!("{empty_msg}"); + warn!("{empty_msg}"); return Ok(None); } - let multi = roots.len() > 1; let mut projects = Vec::with_capacity(roots.len()); for root in &roots { projects.push(load_project(root)?); } - Ok(Some((projects, multi))) -} - -enum Style { - Success, - Error, - Warning, - Header, -} - -fn styled(msg: impl AsRef, style: Style) -> String { - let s = msg.as_ref(); - match style { - Style::Success => format!(" {}", s.green().bold()), - Style::Error => format!(" {}", s.red().bold()), - Style::Warning => format!(" {}", s.yellow().bold()), - Style::Header => s.bold().underline().to_string(), - } + Ok(Some(projects)) } fn print_project_header(name: &str) { - eprintln!("── {} ──", name); -} - -fn check_file(root: &Path, filename: &str, hint: &str) { - let path = root.join(filename); - if path.exists() { - eprintln!("{}", styled(format!("{filename} found"), Style::Success)); - } else if hint.is_empty() { - eprintln!("{}", styled(format!("{filename} not found"), Style::Error)); - } else { - eprintln!( - "{}", - styled(format!("{filename} not found - {hint}"), Style::Error) - ); - } + eprintln!("{}", styled(format!("── {name} ──"), Style::Header)); } -impl Command { +impl CliCommand { pub async fn execute(self) -> Result<(), CiteError> { match self { - Command::Init { name, path } => { + CliCommand::Init { name, path } => { let root = match path { Some(p) => PathBuf::from(p), - None => std::env::current_dir() - .unwrap_or_else(|_| PathBuf::from(".")) - .join(&name), + None => resolve_path(None).join(&name), }; let report = scaffold::init_project(&name, &root)?; for d in &report.directories_created { - eprintln!(" {} (created)", d); + info!("Created directory: {d}"); } for f in &report.files_created { - eprintln!(" {} (created)", f); + info!("Created file: {f}"); } for f in &report.files_skipped { - eprintln!(" {} (skipped)", f); + info!("Skipped: {f}"); } eprintln!( "{}", styled( format!("Project '{name}' ready at {}", root.display()), - Style::Success + Style::Success, ) ); Ok(()) } - Command::Validate { path } => { - let Some((projects, multi)) = - load_projects(path, "No projects found (no cite.toml found)")? + CliCommand::Lint { path } => { + let Some(projects) = load_projects(path, "No projects found (no cite.toml found)")? else { return Ok(()); }; - let mut has_errors = false; + let multi = projects.len() > 1; + let mut overall_has_warnings = false; for ctx in &projects { if multi { print_project_header(&ctx.manifest.project.name); } - let report = validation::validate_all(ctx); - report.print(); - if report.has_errors() { - has_errors = true; + let outcome = doctor::lint_all(ctx); + outcome.print(); + if outcome.has_warnings() { + overall_has_warnings = true; } } - if has_errors { - Err(CiteError::Validation("Validation failed".to_string())) - } else { - Ok(()) - } - } - Command::Lint { path } => { - let Some((projects, multi)) = - load_projects(path, "No projects found (no cite.toml found)")? - else { - return Ok(()); - }; - for ctx in &projects { - if multi { - print_project_header(&ctx.manifest.project.name); - } - let report = validation::lint_all(ctx); - report.print(); + if !overall_has_warnings { + eprintln!( + "{}", + styled("Lint complete — no issues found", Style::Success) + ); } Ok(()) } - Command::Build { path, force } => { - let Some((projects, multi)) = - load_projects(path, "No projects found (no cite.toml found)")? + CliCommand::Build { path, force } => { + let Some(projects) = load_projects(path, "No projects found (no cite.toml found)")? else { return Ok(()); }; + let multi = projects.len() > 1; let mut has_errors = false; for ctx in &projects { if multi { @@ -249,33 +189,36 @@ impl Command { match compiler::compile(ctx, force).await { Ok(report) => report.print(), Err(e) => { - eprintln!("{}", styled(format!("Build failed: {e}"), Style::Error)); + warn!("Build failed: {e}"); has_errors = true; } } } if has_errors { - Err(CiteError::Deploy( + Err(CiteError::Config( "Build failed in one or more projects".to_string(), )) } else { Ok(()) } } - Command::Deploy { path, dry_run } => { - let Some((projects, multi)) = - load_projects(path, "No projects found (no cite.toml found)")? + CliCommand::Deploy { path, dry_run } => { + let Some(projects) = load_projects(path, "No projects found (no cite.toml found)")? else { return Ok(()); }; + let multi = projects.len() > 1; let mut has_errors = false; for ctx in &projects { if multi { print_project_header(&ctx.manifest.project.name); } - if let Err(e) = deploy::deploy(ctx, dry_run).await { - eprintln!("{}", styled(format!("Deploy failed: {e}"), Style::Error)); - has_errors = true; + match deploy::deploy(ctx, dry_run).await { + Ok(msg) => eprintln!("{msg}"), + Err(e) => { + warn!("Deploy failed: {e}"); + has_errors = true; + } } } if has_errors { @@ -286,10 +229,11 @@ impl Command { Ok(()) } } - Command::Status { path } => { - let Some((projects, multi)) = load_projects(path, "No projects found")? else { + CliCommand::Status { path } => { + let Some(projects) = load_projects(path, "No projects found")? else { return Ok(()); }; + let multi = projects.len() > 1; for ctx in &projects { if multi { print_project_header(&ctx.manifest.project.name); @@ -299,9 +243,10 @@ impl Command { eprintln!(" Name: {}", ctx.manifest.project.name); eprintln!(" Project Root: {}", ctx.root.display()); eprintln!(" Artist ID: {}", ctx.manifest.project.artist_id); - if let Some(backend) = &ctx.manifest.backend { - eprintln!(" Active subscription: {}", backend.subscription_plan); - eprintln!(" Publishing to: {}", backend.staging_url); + if let Some(backend) = &ctx.manifest.backend + && let Some(url) = &backend.staging_url + { + eprintln!(" Publishing to: {url}"); } eprintln!(" Podcasts: {}", ctx.metadata.podcasts.len()); let build_path = ctx.build_dir().join("content.json"); @@ -327,83 +272,47 @@ impl Command { } Ok(()) } - Command::Doctor { path } => { + CliCommand::Doctor { path } => { let root = resolve_path(path.clone()); - let Some((projects, multi)) = load_projects(path, "")? else { - eprintln!("{}", styled("Running diagnostics", Style::Header)); - check_file(&root, "cite.toml", "run 'cite-cli init'"); - check_file(&root, "metadata.yml", ""); + let Some(projects) = load_projects(path, "")? else { + info!("Running diagnostics"); + doctor::check_file(&root, "cite.toml", "run 'cite-cli init'"); + doctor::check_file(&root, "metadata.yml", ""); return Ok(()); }; + let multi = projects.len() > 1; + let mut overall_has_errors = false; + let mut overall_has_warnings = false; for ctx in &projects { if multi { print_project_header(&ctx.manifest.project.name); - } else { - eprintln!("{}", styled("Running diagnostics", Style::Header)); - } - check_file(&ctx.root, "cite.toml", "run 'cite-cli init'"); - check_file(&ctx.root, "metadata.yml", ""); - for dir in &["content", "assets/audio", "assets/images", "build"] { - let d = ctx.root.join(dir); - if d.is_dir() { - eprintln!("{}", styled(format!("{dir}/ exists"), Style::Success)); - } else if *dir == "build" { - eprintln!(" {dir}/ missing (created by build)"); - } else { - eprintln!(" {dir}/ missing (will be created on init)"); - } - } - if ctx.manifest.backend.is_some() { - eprintln!( - "{}", - styled("Backend configured for staging", Style::Success) - ); - } else { - eprintln!(" No backend configured (deploy will fail)"); } - if ctx.manifest.build.incremental { - eprintln!("{}", styled("Incremental builds enabled", Style::Success)); + let outcome = doctor::run(ctx)?; + outcome.print(); + if outcome.has_errors() { + overall_has_errors = true; } - if ctx.manifest.project.artist_id.is_empty() { - eprintln!( - "{}", - styled( - "Artist ID is empty - set it in [project] in cite.toml", - Style::Warning - ) - ); - } else { - eprintln!(" Artist ID: {}", ctx.manifest.project.artist_id); - } - if std::env::var("CITE_STAGING_SERVICE_KEY").is_ok() { - eprintln!( - "{}", - styled("CITE_STAGING_SERVICE_KEY env var set", Style::Success) - ); - } else if ctx - .manifest - .backend - .as_ref() - .map(|b| !b.staging_service_key.is_empty()) - .unwrap_or(false) - { - eprintln!(" Using inline staging_service_key from cite.toml"); - } else { - eprintln!( - "{}", - styled( - "No staging service key found - deploy will fail", - Style::Warning - ) - ); + if outcome.has_warnings() { + overall_has_warnings = true; } } + if overall_has_errors { + return Err(CiteError::Config( + "Doctor found validation errors".to_string(), + )); + } else if !overall_has_warnings { + eprintln!( + "{}", + styled("Doctor check complete — no issues found", Style::Success) + ); + } Ok(()) } - Command::Clean { path } => { - let Some((projects, multi)) = load_projects(path, "No projects found")? else { + CliCommand::Clean { path } => { + let Some(projects) = load_projects(path, "No projects found")? else { return Ok(()); }; + let multi = projects.len() > 1; for ctx in &projects { if multi { print_project_header(&ctx.manifest.project.name); @@ -413,13 +322,28 @@ impl Command { } Ok(()) } - Command::Rollback { id, path } => { + CliCommand::Rollback { id, path } => { let root = resolve_path(path); let ctx = load_project(&root)?; - deploy::rollback(&ctx, &id).await + let msg = deploy::rollback(&ctx, &id).await?; + eprintln!("{msg}"); + Ok(()) + } + CliCommand::Login { + email, + password, + path, + } => { + let root = resolve_path(path); + let ctx = load_project(&root)?; + deploy::login(&ctx, email, password).await + } + CliCommand::Upgrade => { + let msg = upgrade::upgrade().await?; + eprintln!("{msg}"); + Ok(()) } - Command::Upgrade => upgrade::upgrade().await, - Command::Uninstall { force } => uninstall::uninstall(force), + CliCommand::Uninstall { force } => uninstall::uninstall(force), } } } diff --git a/src/cache.rs b/src/core/cache.rs similarity index 80% rename from src/cache.rs rename to src/core/cache.rs index 7f9dd9d..073ec48 100644 --- a/src/cache.rs +++ b/src/core/cache.rs @@ -1,4 +1,3 @@ -use crate::error::CiteError; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::HashMap; @@ -6,6 +5,8 @@ use std::path::Path; use tokio::io::AsyncReadExt; use tracing::instrument; +use crate::core::CiteError; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BuildCache { pub compiler_version: f64, @@ -21,18 +22,16 @@ impl BuildCache { } pub async fn load_or_default(path: &Path) -> Result { - if path.exists() { - let data = tokio::fs::read_to_string(path).await?; - Ok(serde_json::from_str(&data).unwrap_or_else(|_| BuildCache { - compiler_version: 0.0, - hashes: HashMap::new(), - })) - } else { - Ok(BuildCache { - compiler_version: 0.0, - hashes: HashMap::new(), - }) + if path.exists() + && let Ok(data) = tokio::fs::read_to_string(path).await + && let Ok(cache) = serde_json::from_str(&data) + { + return Ok(cache); } + Ok(Self { + compiler_version: 0.0, + hashes: HashMap::new(), + }) } pub async fn save(&self, path: &Path) -> Result<(), CiteError> { @@ -114,4 +113,16 @@ mod tests { let changed = cache.changed_since(¤t); assert_eq!(changed, vec!["a.md"]); } + + #[test] + fn test_changed_since_file_removed() { + let mut hashes = HashMap::new(); + hashes.insert("a.md".into(), "abc".into()); + hashes.insert("b.md".into(), "def".into()); + let cache = BuildCache::new(0.0, hashes); + let mut current = HashMap::new(); + current.insert("a.md".into(), "abc".into()); + let changed = cache.changed_since(¤t); + assert_eq!(changed, vec!["b.md"]); + } } diff --git a/src/compiler.rs b/src/core/compiler.rs similarity index 53% rename from src/compiler.rs rename to src/core/compiler.rs index 67c08c0..e9ac017 100644 --- a/src/compiler.rs +++ b/src/core/compiler.rs @@ -1,12 +1,80 @@ -use crate::cache::{self, BuildCache}; -use crate::error::{CiteError, ValidationReport}; -use crate::metadata::{ContentBundle, Timeline, TimelineEntry}; -use crate::project::ProjectContext; +use std::path::PathBuf; + +use crate::core::cache::{self, BuildCache}; +use crate::core::metadata::{Podcast, TimelineEntry}; +use crate::core::project::ProjectContext; +use crate::core::{CiteError, Style, styled}; +use serde::Serialize; use tracing::instrument; use uuid::Uuid; +#[derive(Debug, Clone, Serialize)] +pub struct ContentBundle { + pub compiler_version: f64, + pub project: String, + pub artist_id: String, + pub podcasts: Vec, + pub timelines: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct BundlePodcast { + pub id: String, + #[serde(flatten)] + pub podcast: Podcast, + pub content: Option, +} + +impl From<&Podcast> for BundlePodcast { + fn from(p: &Podcast) -> Self { + Self { + id: Uuid::new_v4().to_string(), + podcast: p.clone(), + content: None, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct BundleTimeline { + pub id: String, + pub entries: Vec, +} + +pub enum CompileOutcome { + UpToDate, + Complete { podcasts: usize, artifact: PathBuf }, +} + +impl CompileOutcome { + pub fn print(&self) { + for line in self.to_lines() { + eprintln!("{line}"); + } + } + + pub fn to_lines(&self) -> Vec { + match self { + CompileOutcome::UpToDate => { + vec![styled( + "Nothing to rebuild — all files up to date", + Style::Success, + )] + } + CompileOutcome::Complete { podcasts, artifact } => vec![ + styled(format!("Built {} podcast items", podcasts), Style::Info), + styled( + format!("Build artifact at {}", artifact.display()), + Style::Info, + ), + styled("Build completed successfully", Style::Success), + ], + } + } +} + #[instrument(skip(ctx), fields(project = %ctx.manifest.project.name, force))] -pub async fn compile(ctx: &ProjectContext, force: bool) -> Result { +pub async fn compile(ctx: &ProjectContext, force: bool) -> Result { let cache_path = ctx.cache_path(); let content_files = ctx.content_files(); @@ -17,7 +85,7 @@ pub async fn compile(ctx: &ProjectContext, force: bool) -> Result Result Result { - let mut podcasts = ctx.metadata.podcasts.clone(); + let mut podcasts: Vec = ctx + .metadata + .podcasts + .iter() + .map(BundlePodcast::from) + .collect(); let mut timelines = Vec::new(); + let normalize_asset = |path: &str| format!("assets/{}", path.trim_start_matches("assets/")); + for item in &mut podcasts { - if !item.file.is_empty() { - let src = ctx.root.join(&item.file); + if !item.podcast.file.is_empty() { + let src = ctx.root.join(&item.podcast.file); if src.exists() && src.is_file() { - let raw = tokio::fs::read_to_string(&src).await.unwrap_or_default(); + let raw = tokio::fs::read_to_string(&src).await?; item.content = Some(raw); } } - if let Some(thumbnail) = &item.thumbnail { - item.thumbnail = Some(format!( - "assets/{}", - thumbnail.trim_start_matches("assets/") - )); - } - - if let Some(audio) = &item.audio { - let rewritten = format!("assets/{}", audio.trim_start_matches("assets/")); - item.audio = Some(rewritten); - } + item.podcast.thumbnail = item.podcast.thumbnail.as_deref().map(normalize_asset); + item.podcast.audio = item.podcast.audio.as_deref().map(normalize_asset); + } - if let Some(citation) = &item.citation { + for p in &ctx.metadata.podcasts { + if let Some(citation) = &p.citation { let bib_src = ctx.root.join(citation); if bib_src.exists() { - let bib_content = tokio::fs::read_to_string(&bib_src) - .await - .unwrap_or_default(); + let bib_content = tokio::fs::read_to_string(&bib_src).await?; let entries = parse_bibtex(&bib_content); if !entries.is_empty() { - let id = Uuid::new_v4().to_string(); - timelines.push(Timeline { id, entries }); + timelines.push(BundleTimeline { + id: Uuid::new_v4().to_string(), + entries, + }); } } } @@ -111,6 +173,27 @@ fn parse_bibtex(content: &str) -> Vec { Some(i) => pos + i, None => break, }; + let entry_type = content[pos..open].trim().to_lowercase(); + if matches!( + entry_type.as_str(), + "comment" | "string" | "preamble" | "xdata" + ) { + let mut depth = 1; + for (offset, &b) in bytes[open + 1..].iter().enumerate() { + match b { + b'{' => depth += 1, + b'}' => { + depth -= 1; + if depth == 0 { + pos = open + 1 + offset + 1; + break; + } + } + _ => {} + } + } + continue; + } pos = open + 1; let mut depth = 1; @@ -152,8 +235,8 @@ fn parse_bibtex(content: &str) -> Vec { let id = Uuid::new_v4().to_string(); entries.push(TimelineEntry { - date, id, + date: Some(date), title: entry_title, summary: Some(summary), url: Some(url), @@ -232,7 +315,7 @@ fn format_bib_date(year: &Option, month: &Option) -> String { "aug" | "august" => "08", "sep" | "september" => "09", "oct" | "october" => "10", - "nov" | "november" => "11", + "nov" | "novermber" => "11", "dec" | "december" => "12", _ => return None, }) @@ -249,9 +332,7 @@ fn format_title(title: &str, author: &str) -> String { if title.is_empty() { return author.to_string(); } - let cleaned = title - .trim_matches(|c: char| c == '{' || c == '}') - .to_string(); + let cleaned: String = title.chars().filter(|&c| c != '{' && c != '}').collect(); if author.is_empty() { cleaned } else { @@ -262,9 +343,6 @@ fn format_title(title: &str, author: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crate::manifest::Manifest; - use crate::metadata::{Metadata, Podcast}; - use crate::project::ProjectContext; #[test] fn test_parse_bibtex_extracts_timeline_entries() { @@ -280,7 +358,7 @@ mod tests { "#; let entries = parse_bibtex(bib); assert_eq!(entries.len(), 1); - assert_eq!(entries[0].date, "1935-05"); + assert_eq!(entries[0].date.as_deref(), Some("1935-05")); assert!(entries[0].title.contains("Quantum-Mechanical")); assert_eq!(entries[0].url.as_deref(), Some("10.1038/35057060")); } @@ -305,74 +383,115 @@ mod tests { assert_eq!(parse_bibtex(bib).len(), 2); } - #[tokio::test] - async fn test_build_empty_metadata() { - let dir = tempfile::tempdir().unwrap(); - let ctx = ProjectContext { - root: dir.path().to_path_buf(), - manifest: Manifest::default_template("test"), - metadata: Metadata::default(), - }; - let report = compile(&ctx, false).await.unwrap(); - assert!(!report.has_errors()); + #[test] + fn test_format_title_with_author() { + assert_eq!( + format_title("My Paper", "Smith, J."), + "My Paper — Smith, J." + ); } - #[tokio::test] - async fn test_build_creates_content_json() { - let dir = tempfile::tempdir().unwrap(); - let content_dir = dir.path().join("content"); - std::fs::create_dir_all(&content_dir).unwrap(); - std::fs::write(content_dir.join("article.md"), "# Hello").unwrap(); - - let mut manifest = Manifest::default_template("test"); - manifest.project.artist_id = "abc".into(); - let ctx = ProjectContext { - root: dir.path().to_path_buf(), - manifest, - metadata: Metadata { - podcasts: vec![Podcast { - id: "abc".into(), - title: "My Podcast".into(), - file: "content/article.md".into(), - source_url: None, - category: Some("tech".into()), - thumbnail: None, - audio: None, - citation: None, - content: None, - }], - }, - }; - let report = compile(&ctx, false).await.unwrap(); - assert!(!report.has_errors()); - assert!(ctx.build_dir().join("content.json").exists()); - - let json_str = std::fs::read_to_string(ctx.build_dir().join("content.json")).unwrap(); - let bundle: ContentBundle = serde_json::from_str(&json_str).unwrap(); - assert_eq!(bundle.podcasts.len(), 1); - assert_eq!(bundle.podcasts[0].id, "abc"); - assert_eq!(bundle.podcasts[0].content.as_deref(), Some("# Hello")); - assert!(bundle.podcasts[0].citation.is_none()); - assert_eq!(bundle.podcasts[0].category.as_deref(), Some("tech")); + #[test] + fn test_format_title_without_author() { + assert_eq!(format_title("My Paper", ""), "My Paper"); } - #[tokio::test] - async fn test_build_force_rebuild() { - let dir = tempfile::tempdir().unwrap(); - let ctx = ProjectContext { - root: dir.path().to_path_buf(), - manifest: Manifest::default_template("test"), - metadata: Metadata::default(), - }; - let r1 = compile(&ctx, false).await.unwrap(); - assert!(!r1.has_errors()); + #[test] + fn test_format_title_without_title() { + assert_eq!(format_title("", "Smith, J."), "Smith, J."); + } - let r2 = compile(&ctx, false).await.unwrap(); - assert!(!r2.has_errors()); - assert!(r2.infos.is_empty()); + #[test] + fn test_format_title_trims_surrounding_braces() { + assert_eq!(format_title("{E}nsemble {M}ethods", ""), "Ensemble Methods"); + } - let r3 = compile(&ctx, true).await.unwrap(); - assert!(!r3.has_errors()); - assert!(!r3.infos.is_empty()); + #[test] + fn test_format_bib_date_year_only() { + let year = Some("2023".to_string()); + let month = None; + assert_eq!(format_bib_date(&year, &month), "2023"); + } + + #[test] + fn test_format_bib_date_year_month() { + let year = Some("2023".to_string()); + let month = Some("may".to_string()); + assert_eq!(format_bib_date(&year, &month), "2023-05"); + } + + #[test] + fn test_format_bib_date_full_month() { + let year = Some("2023".to_string()); + let month = Some("January".to_string()); + assert_eq!(format_bib_date(&year, &month), "2023-01"); + } + + #[test] + fn test_format_bib_date_empty() { + let year = None; + let month = None; + assert_eq!(format_bib_date(&year, &month), ""); + } + + #[test] + fn test_format_bib_date_invalid_month() { + let year = Some("2023".to_string()); + let month = Some("invalid".to_string()); + assert_eq!(format_bib_date(&year, &month), "2023"); + } + + #[test] + fn test_parse_bibtex_protective_braces() { + let bib = r#" +@article{test, + title = {The Great Paper}, + year = {2023}, +} +"#; + let entries = parse_bibtex(bib); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].title, "The Great Paper"); + } + + #[test] + fn test_parse_bibtex_entry_without_title() { + let bib = r#" +@misc{no-title, + author = {Doe, J.}, + year = {2023}, +} +"#; + let entries = parse_bibtex(bib); + assert_eq!(entries.len(), 1); + assert!(entries[0].title.contains("Doe")); + } + + #[test] + fn test_parse_bibtex_no_url_or_doi() { + let bib = r#" +@article{minimal, + title = {Minimal Entry}, +} +"#; + let entries = parse_bibtex(bib); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].url.as_deref(), Some("")); + } + + #[test] + fn test_parse_bibtex_skips_non_entries() { + let bib = r#" +@comment{ this should be ignored } +@string{ key = "value" } +@preamble{ "x" } +@article{real, + title = {Real Entry}, + year = {2023}, +} +"#; + let entries = parse_bibtex(bib); + assert_eq!(entries.len(), 1); + assert!(entries[0].title.contains("Real Entry")); } } diff --git a/src/deploy.rs b/src/core/deploy.rs similarity index 58% rename from src/deploy.rs rename to src/core/deploy.rs index 2994a6a..f3762d5 100644 --- a/src/deploy.rs +++ b/src/core/deploy.rs @@ -1,24 +1,27 @@ -use crate::error::CiteError; -use crate::manifest::BackendConfig; -use crate::project::ProjectContext; -use colored::Colorize; +use std::path::PathBuf; + use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; use reqwest::RequestBuilder; use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::env; -use std::path::PathBuf; -use tracing::instrument; +use tracing::{info, instrument, warn}; use uuid::Uuid; +use crate::core::manifest::BackendConfig; +use crate::core::project::ProjectContext; +use crate::core::{CiteError, Style, styled}; + const STORAGE_BUCKET: &str = "assets"; #[derive(Debug, Clone)] struct DeployContext { client: reqwest::Client, base_url: String, - service_key: String, + api_key: String, + bearer: String, root: PathBuf, + project_name: String, + artist_id: Uuid, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -27,19 +30,26 @@ struct DeploymentRecord { storage_path: String, news_ids: Vec, timeline_ids: Vec, + #[serde(default)] + asset_paths: Vec, } #[derive(Debug)] struct DeployedPodcast { news_id: i64, - timeline_ids: Vec, + asset_paths: Vec, +} + +#[derive(Debug)] +struct UploadedAsset { + storage_path: String, + public_url: String, } -fn with_auth(builder: RequestBuilder, service_key: &str) -> RequestBuilder { +fn with_auth(builder: RequestBuilder, api_key: &str, bearer: &str) -> RequestBuilder { builder - .header("apikey", service_key) - .header("Authorization", format!("Bearer {service_key}")) - .header("Content-Type", "application/json") + .header("apikey", api_key) + .header("Authorization", format!("Bearer {bearer}")) } fn encode_url(s: &str) -> String { @@ -50,25 +60,61 @@ fn now_rfc3339() -> String { chrono::Utc::now().to_rfc3339() } +fn build_context( + ctx: &ProjectContext, + backend: &BackendConfig, + project_name: String, + artist_id: Uuid, +) -> Result { + let session = load_session(); + Ok(DeployContext { + client: reqwest::Client::new(), + base_url: backend + .staging_url + .as_deref() + .unwrap_or_default() + .trim_end_matches('/') + .to_string(), + api_key: backend.staging_service_key.clone().unwrap_or_default(), + bearer: resolve_bearer(backend, session.as_ref())?, + root: ctx.root.clone(), + project_name, + artist_id, + }) +} + +async fn ensure_success( + response: reqwest::Response, + context: impl std::fmt::Display, +) -> Result { + if response.status().is_success() { + return Ok(response); + } + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + Err(CiteError::Deploy(format!( + "{context}: HTTP {status} - {body}" + ))) +} + #[instrument(skip(ctx), fields(project = %ctx.manifest.project.name, dry_run))] -pub async fn deploy(ctx: &ProjectContext, dry_run: bool) -> Result<(), CiteError> { - let backend = ctx - .manifest - .backend - .as_ref() - .ok_or_else(|| CiteError::Deploy("No [backend] section in cite.toml".to_string()))?; - - let service_key = resolve_service_key(backend)?; +pub async fn deploy(ctx: &ProjectContext, dry_run: bool) -> Result { + let Some(backend) = &ctx.manifest.backend else { + return Err(CiteError::Config( + "No [backend] section in cite.toml (set staging_url)".to_string(), + )); + }; + let bundle_path = ctx.build_dir().join("content.json"); if !bundle_path.exists() { - return Err(CiteError::Deploy( + return Err(CiteError::Config( "No build artifact found. Run 'cite-cli build' first.".to_string(), )); } - let bundle_str = std::fs::read_to_string(&bundle_path)?; + let bundle_str = tokio::fs::read_to_string(&bundle_path).await?; let bundle: Value = serde_json::from_str(&bundle_str)?; let deployment_id = Uuid::new_v4().to_string(); - let project_slug = bundle + let project_name = bundle .get("project") .and_then(|v| v.as_str()) .unwrap_or("unknown") @@ -90,83 +136,67 @@ pub async fn deploy(ctx: &ProjectContext, dry_run: bool) -> Result<(), CiteError .trim() .to_string(); - eprintln!( - "{}", - format!("Deploying with deployment_id: {deployment_id}") - .cyan() - .bold() - ); + info!("Deploying: {deployment_id}"); if dry_run { - eprintln!("{}", " DRY RUN - no data will be sent".yellow().bold()); - eprintln!("{}", format!(" Podcast items: {}", podcasts.len()).cyan()); - eprintln!( - "{}", - format!(" Timeline groups: {}", timelines.len()).cyan() - ); + warn!("DRY RUN - no data will be sent"); + info!("Podcast items: {}", podcasts.len()); + info!("Timeline groups: {}", timelines.len()); if !artist_id.is_empty() { - eprintln!("{}", format!(" Artist ID: {artist_id}").cyan()); + info!("Artist ID: {artist_id}"); } - return Ok(()); + return Ok("Dry run complete".to_string()); } let artist_id = Uuid::parse_str(&artist_id).map_err(|_| { - CiteError::Deploy("artist_id in content.json must be a valid UUID".to_string()) + CiteError::Config("artist_id in content.json must be a valid UUID".to_string()) })?; - let client = reqwest::Client::new(); - let base_url = backend.staging_url.trim_end_matches('/').to_string(); - let storage_path = format!("{project_slug}/{deployment_id}.json"); + let dctx = build_context(ctx, backend, project_name.clone(), artist_id)?; + let storage_path = format!("{artist_id}/{project_name}/{deployment_id}.json"); let bundle_bytes = serde_json::to_vec_pretty(&bundle)?; - let public_bundle_url = upload_bytes( - &client, - &base_url, - &service_key, - &storage_path, - &bundle_bytes, - "application/json", - ) - .await?; - eprintln!(" Uploaded bundle to {}", public_bundle_url.cyan()); let mut record = DeploymentRecord { deployment_id: deployment_id.clone(), storage_path: storage_path.clone(), news_ids: Vec::new(), timeline_ids: Vec::new(), - }; - - let dctx = DeployContext { - client, - base_url, - service_key, - root: ctx.root.clone(), + asset_paths: Vec::new(), }; ensure_artist_exists(&dctx, artist_id).await?; - let plan_id = resolve_plan_id(&dctx, &backend.subscription_plan).await?; + let plan_id = fetch_subscription_plan(&dctx).await?; + + let timeline_ids = deploy_timelines(&dctx, &timelines).await?; for pod in &podcasts { - let deployed = deploy_podcast(&dctx, pod, &timelines, artist_id, plan_id).await?; + let deployed = deploy_podcast(&dctx, pod, &timeline_ids, artist_id, plan_id).await?; record.news_ids.push(deployed.news_id); - record.timeline_ids.extend(deployed.timeline_ids); + record.asset_paths.extend(deployed.asset_paths); } + record.timeline_ids = timeline_ids; + + let public_bundle_url = + upload_bytes(&dctx, &storage_path, &bundle_bytes, "application/json").await?; + info!("Uploaded bundle to {public_bundle_url}"); persist_deployment_record(ctx, &record).await?; - eprintln!( - "{}", - format!("Deployment complete (id: {deployment_id})") - .green() - .bold() - ); + let podcast_count = record.news_ids.len(); + let timeline_count = record.timeline_ids.len(); + let asset_count = record.asset_paths.len(); - Ok(()) + Ok(styled( + format!( + "Deployed {podcast_count} podcast(s), {timeline_count} timeline(s), {asset_count} asset(s)" + ), + Style::Success, + )) } async fn deploy_podcast( dctx: &DeployContext, podcast: &Value, - timeline_groups: &[Value], + timeline_ids: &[i64], artist_id: Uuid, plan_id: i64, ) -> Result { @@ -192,50 +222,44 @@ async fn deploy_podcast( ) .await?; - let thumbnail_url = upload_optional_asset( - dctx, - podcast_id, - podcast.get("thumbnail").and_then(|v| v.as_str()), - "thumbnails", - ) - .await?; - - let news_id = insert_news_row( - dctx, - title, - content, - category_id, - url_id, - thumbnail_url.as_deref(), - ) - .await?; + let mut asset_paths = Vec::new(); + let news_id = insert_news_row(dctx, title, content, category_id, url_id, None).await?; + info!("Created news item: {title} (id={news_id})"); ensure_artist_link(dctx, artist_id, news_id).await?; ensure_metric_row(dctx, news_id).await?; - if let Some(audio_url) = upload_optional_asset( + link_timelines_to_news(dctx, timeline_ids, news_id).await?; + + if let Some(asset) = upload_optional_asset( dctx, - podcast_id, - podcast.get("audio").and_then(|v| v.as_str()), - "audio", + podcast.get("thumbnail").and_then(|v| v.as_str()), + "image", ) .await? { - insert_podcast_row(dctx, news_id, title, &audio_url, plan_id).await?; + update_news_thumbnail(dctx, news_id, &asset.public_url).await?; + info!("Uploaded thumbnail: {}", asset.storage_path); + asset_paths.push(asset.storage_path); } - let timeline_ids = deploy_timelines_for_news(dctx, timeline_groups, news_id).await?; + if let Some(audio) = + upload_optional_asset(dctx, podcast.get("audio").and_then(|v| v.as_str()), "audio").await? + { + insert_podcast_row(dctx, news_id, title, &audio.public_url, plan_id).await?; + info!("Created podcast: {title}"); + asset_paths.push(audio.storage_path); + } Ok(DeployedPodcast { news_id, - timeline_ids, + asset_paths, }) } -async fn deploy_timelines_for_news( +async fn deploy_timelines( dctx: &DeployContext, timeline_groups: &[Value], - news_id: i64, ) -> Result, CiteError> { let mut timeline_ids = Vec::new(); @@ -268,27 +292,36 @@ async fn deploy_timelines_for_news( } let timeline_id = insert_row(dctx, "timeline", timeline_payload).await?; - - insert_row( - dctx, - "timeline_news", - build_map(&[ - ("timeline_id", Value::Number(timeline_id.into())), - ("news_id", Value::Number(news_id.into())), - ]), - ) - .await?; timeline_ids.push(timeline_id); - eprintln!(" Deployed timeline entry: {}", title.cyan()); + info!("Deployed timeline entry: {title}"); } } if !timeline_ids.is_empty() { - eprintln!(" Linked {} timeline entries", timeline_ids.len()); + info!("Deployed {} timeline entries", timeline_ids.len()); } Ok(timeline_ids) } +async fn link_timelines_to_news( + dctx: &DeployContext, + timeline_ids: &[i64], + news_id: i64, +) -> Result<(), CiteError> { + for timeline_id in timeline_ids { + insert_row( + dctx, + "timeline_news", + build_map(&[ + ("timeline_id", Value::Number((*timeline_id).into())), + ("news_id", Value::Number(news_id.into())), + ]), + ) + .await?; + } + Ok(()) +} + async fn persist_deployment_record( ctx: &ProjectContext, record: &DeploymentRecord, @@ -320,16 +353,10 @@ async fn load_deployment_record( async fn delete_row_by_id(dctx: &DeployContext, table: &str, id: i64) -> Result<(), CiteError> { let url = format!("{}/rest/v1/{table}?id=eq.{id}", dctx.base_url); - let response = with_auth(dctx.client.delete(&url), &dctx.service_key) + let response = with_auth(dctx.client.delete(&url), &dctx.api_key, &dctx.bearer) .send() .await?; - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - return Err(CiteError::Deploy(format!( - "Failed to delete {table} row {id}: HTTP {status} - {body}" - ))); - } + ensure_success(response, format!("Failed to delete {table} row {id}")).await?; Ok(()) } @@ -338,85 +365,312 @@ async fn delete_storage_object(dctx: &DeployContext, storage_path: &str) -> Resu "{}/storage/v1/object/{STORAGE_BUCKET}/{storage_path}", dctx.base_url ); - let response = with_auth(dctx.client.delete(&url), &dctx.service_key) + let response = with_auth(dctx.client.delete(&url), &dctx.api_key, &dctx.bearer) .send() .await?; - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - return Err(CiteError::Deploy(format!( - "Failed to delete storage object {storage_path}: HTTP {status} - {body}" - ))); - } + ensure_success( + response, + format!("Failed to delete storage object {storage_path}"), + ) + .await?; Ok(()) } #[instrument(skip(ctx), fields(id = %deployment_id))] -pub async fn rollback(ctx: &ProjectContext, deployment_id: &str) -> Result<(), CiteError> { - let backend = ctx - .manifest - .backend - .as_ref() - .ok_or_else(|| CiteError::Deploy("No [backend] section in cite.toml".to_string()))?; - - let service_key = resolve_service_key(backend)?; - let client = reqwest::Client::new(); - let base_url = backend.staging_url.trim_end_matches('/').to_string(); - let (record_path, record) = load_deployment_record(ctx, deployment_id).await?; - - let dctx = DeployContext { - client, - base_url, - service_key, - root: ctx.root.clone(), +pub async fn rollback(ctx: &ProjectContext, deployment_id: &str) -> Result { + let Some(backend) = &ctx.manifest.backend else { + return Err(CiteError::Config( + "No [backend] section in cite.toml (set staging_url)".to_string(), + )); }; - eprintln!( - "{}", - format!("Rolling back deployment: {deployment_id}") - .yellow() - .bold() - ); + let (record_path, record) = load_deployment_record(ctx, deployment_id).await?; + let artist_id = Uuid::parse_str(&ctx.manifest.project.artist_id).map_err(|_| { + CiteError::Config("artist_id in cite.toml must be a valid UUID".to_string()) + })?; + let dctx = build_context(ctx, backend, ctx.manifest.project.name.clone(), artist_id)?; + + warn!("Rolling back deployment: {deployment_id}"); for timeline_id in &record.timeline_ids { delete_row_by_id(&dctx, "timeline", *timeline_id).await?; - eprintln!(" Cleared timeline {timeline_id}"); + info!("Cleared timeline {timeline_id}"); } for news_id in &record.news_ids { delete_row_by_id(&dctx, "news", *news_id).await?; - eprintln!(" Cleared news {news_id}"); + info!("Cleared news {news_id}"); + } + + for asset_path in &record.asset_paths { + if let Err(e) = delete_storage_object(&dctx, asset_path).await { + warn!("Failed to delete asset {asset_path}: {e}"); + } else { + info!("Cleared asset {asset_path}"); + } } if let Err(e) = delete_storage_object(&dctx, &record.storage_path).await { - eprintln!(" {} {e}", "warning:".yellow().bold()); + warn!("Failed to delete storage: {e}"); } else { - eprintln!(" Cleared storage object"); + info!("Cleared storage object"); } if let Err(e) = tokio::fs::remove_file(&record_path).await { - eprintln!( - " {} Failed to remove local deployment record: {e}", - "warning:".yellow().bold() - ); + warn!("Failed to remove local deployment record: {e}"); } - eprintln!("{}", "Rollback complete".green().bold()); + Ok(styled("Rollback complete", Style::Success)) +} + +#[derive(Deserialize)] +struct TokenResponse { + access_token: String, + refresh_token: String, +} + +#[derive(Serialize, Deserialize, Debug)] +struct Session { + access_token: String, + refresh_token: String, + email: String, +} + +fn session_path() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + PathBuf::from(home).join(".cite").join("session.json") +} + +fn load_session() -> Option { + let path = session_path(); + let content = std::fs::read_to_string(&path).ok()?; + serde_json::from_str(&content).ok() +} + +fn save_session(session: &Session) -> Result<(), CiteError> { + let path = session_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, serde_json::to_string_pretty(session)?)?; Ok(()) } -fn resolve_service_key(backend: &BackendConfig) -> Result { - let key = env::var("CITE_STAGING_SERVICE_KEY") - .unwrap_or_else(|_| backend.staging_service_key.clone()); +fn resolve_bearer(backend: &BackendConfig, session: Option<&Session>) -> Result { + if let Some(s) = session { + return Ok(s.access_token.clone()); + } + match backend.staging_service_key.as_deref() { + Some(key) if !key.is_empty() => Ok(key.to_string()), + _ => Err(CiteError::Auth( + "Not logged in and no backend.staging_service_key configured. Run 'cite-cli login' or set the key in cite.toml" + .to_string(), + )), + } +} - if key.is_empty() { - Err(CiteError::Deploy( - "No service key found. Set CITE_STAGING_SERVICE_KEY env var or configure backend.staging_service_key in cite.toml" +pub async fn login( + ctx: &ProjectContext, + email: Option, + password: Option, +) -> Result<(), CiteError> { + let Some(backend) = &ctx.manifest.backend else { + return Err(CiteError::Config( + "No [backend] section in cite.toml (set staging_url)".to_string(), + )); + }; + if backend + .staging_service_key + .as_deref() + .is_none_or(|s| s.is_empty()) + { + return Err(CiteError::Auth( + "backend.staging_service_key (Supabase anon/publishable key) is required for login" .to_string(), - )) - } else { - Ok(key) + )); + } + + let email = match email { + Some(e) => e, + None => { + eprint!("Email: "); + let mut s = String::new(); + std::io::stdin().read_line(&mut s)?; + s.trim().to_string() + } + }; + let password = match password { + Some(p) => p, + None => { + eprint!("Password: "); + let mut s = String::new(); + std::io::stdin().read_line(&mut s)?; + s.trim().to_string() + } + }; + + let client = reqwest::Client::new(); + let url = format!( + "{}/auth/v1/token?grant_type=password", + backend + .staging_url + .as_deref() + .unwrap_or_default() + .trim_end_matches('/') + ); + let response = client + .post(&url) + .header( + "apikey", + backend.staging_service_key.as_deref().unwrap_or_default(), + ) + .header("Content-Type", "application/json") + .json(&serde_json::json!({ "email": email, "password": password })) + .send() + .await?; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(CiteError::Auth(format!( + "Login failed: HTTP {status} - {body}" + ))); + } + + let token: TokenResponse = serde_json::from_str(&body) + .map_err(|e| CiteError::Auth(format!("Invalid login response: {e}")))?; + + let session = Session { + access_token: token.access_token.clone(), + refresh_token: token.refresh_token, + email: email.clone(), + }; + save_session(&session)?; + eprintln!( + "{}", + styled(format!("Logged in as {email}"), Style::Success) + ); + + match fetch_user_artists(backend, &token.access_token).await { + Ok(artists) if artists.is_empty() => { + eprintln!( + "{}", + styled("No artist linked to this account.", Style::Warning) + ); + match prompt_create_artist(backend, &token.access_token).await? { + Some((id, name)) => { + eprintln!( + "{}", + styled(format!("Created artist '{name}' ({id})"), Style::Success) + ); + } + None => { + info!("Skipped artist creation"); + } + } + } + Ok(artists) => { + info!("Associated artist(s):"); + for (id, name) in &artists { + info!(" - {name} ({id})"); + } + } + Err(e) => { + warn!("Could not fetch artists: {e}"); + } + } + + Ok(()) +} + +async fn fetch_user_artists( + backend: &BackendConfig, + token: &str, +) -> Result, CiteError> { + let url = format!( + "{}/rest/v1/artists?select=id,name", + backend + .staging_url + .as_deref() + .unwrap_or_default() + .trim_end_matches('/') + ); + let resp = with_auth( + reqwest::Client::new().get(&url), + backend.staging_service_key.as_deref().unwrap_or_default(), + token, + ) + .send() + .await?; + if !resp.status().is_success() { + return Err(CiteError::Auth(format!( + "Failed to fetch artists: HTTP {}", + resp.status() + ))); + } + let rows: Vec = resp.json().await?; + Ok(rows + .into_iter() + .filter_map(|r| { + let id = r.get("id")?.as_str()?.to_string(); + let name = r.get("name")?.as_str()?.to_string(); + Some((id, name)) + }) + .collect()) +} + +fn prompt_line(label: &str) -> Result { + eprint!("{label}"); + let mut s = String::new(); + std::io::stdin().read_line(&mut s)?; + Ok(s.trim().to_string()) +} + +async fn prompt_create_artist( + backend: &BackendConfig, + token: &str, +) -> Result, CiteError> { + let name = prompt_line("Artist name: ")?; + if name.is_empty() { + return Ok(None); + } + let description = prompt_line("Description (optional): ")?; + let website = prompt_line("Website URL (optional): ")?; + + let mut payload = build_map(&[("name", Value::String(name.clone()))]); + if !description.is_empty() { + payload.insert("description".into(), Value::String(description)); + } + if !website.is_empty() { + payload.insert("website_url".into(), Value::String(website)); } + + let url = format!( + "{}/rest/v1/artists", + backend + .staging_url + .as_deref() + .unwrap_or_default() + .trim_end_matches('/') + ); + let resp = with_auth( + reqwest::Client::new().post(&url), + backend.staging_service_key.as_deref().unwrap_or_default(), + token, + ) + .header("Prefer", "return=representation") + .json(&payload) + .send() + .await?; + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(CiteError::Auth(format!( + "Failed to create artist: HTTP {status} - {body}" + ))); + } + let row: Value = resp.json().await?; + let id = extract_uuid(&row).unwrap_or_default(); + Ok(Some((id, name))) } fn build_map(fields: &[(&str, Value)]) -> serde_json::Map { @@ -437,6 +691,16 @@ fn extract_id(value: &Value) -> Option { } } +fn extract_uuid(value: &Value) -> Option { + match value { + Value::Array(rows) => rows + .first() + .and_then(|row| row.get("id").and_then(|id| id.as_str().map(String::from))), + Value::Object(map) => map.get("id").and_then(|id| id.as_str().map(String::from)), + _ => None, + } +} + async fn lookup_row_id( dctx: &DeployContext, table: &str, @@ -448,7 +712,7 @@ async fn lookup_row_id( &dctx.base_url, encode_url(value) ); - let resp = with_auth(dctx.client.get(&url), &dctx.service_key) + let resp = with_auth(dctx.client.get(&url), &dctx.api_key, &dctx.bearer) .send() .await?; if !resp.status().is_success() { @@ -466,18 +730,12 @@ async fn insert_row( payload: serde_json::Map, ) -> Result { let url = format!("{}/rest/v1/{table}", dctx.base_url); - let response = with_auth(dctx.client.post(&url), &dctx.service_key) + let response = with_auth(dctx.client.post(&url), &dctx.api_key, &dctx.bearer) .header("Prefer", "return=representation") .json(&payload) .send() .await?; - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - return Err(CiteError::Deploy(format!( - "Failed to insert into {table}: HTTP {status} - {body}" - ))); - } + let response = ensure_success(response, format!("Failed to insert into {table}")).await?; let row: Value = response.json().await?; extract_id(&row) @@ -486,18 +744,18 @@ async fn insert_row( async fn ensure_artist_exists(dctx: &DeployContext, artist_id: Uuid) -> Result<(), CiteError> { let url = format!("{}/rest/v1/artists?id=eq.{}", &dctx.base_url, artist_id); - let resp = with_auth(dctx.client.get(&url), &dctx.service_key) + let resp = with_auth(dctx.client.get(&url), &dctx.api_key, &dctx.bearer) .send() .await?; if !resp.status().is_success() { - return Err(CiteError::Deploy(format!( + return Err(CiteError::Auth(format!( "Failed to verify artist {artist_id}: HTTP {}", resp.status() ))); } let rows: Vec = resp.json().await?; if rows.is_empty() { - return Err(CiteError::Deploy(format!( + return Err(CiteError::Auth(format!( "Artist '{artist_id}' does not exist in the database. Create the artist before deploying." ))); } @@ -580,10 +838,9 @@ async fn ensure_url_id( async fn upload_optional_asset( dctx: &DeployContext, - podcast_id: &str, asset: Option<&str>, kind: &str, -) -> Result, CiteError> { +) -> Result, CiteError> { let Some(asset_path) = asset.filter(|value| !value.trim().is_empty()) else { return Ok(None); }; @@ -598,23 +855,22 @@ async fn upload_optional_asset( .file_name() .and_then(|value| value.to_str()) .unwrap_or("asset.bin"); - let storage_path = format!("{}/{}/{}", kind, podcast_id, file_name); + let storage_path = format!( + "{}/{}/{}/{}", + dctx.artist_id, dctx.project_name, kind, file_name + ); let mime = local_path .extension() .and_then(|value| value.to_str()) .map(mime_for_extension) .unwrap_or("application/octet-stream"); - upload_bytes( - &dctx.client, - &dctx.base_url, - &dctx.service_key, - &storage_path, - &bytes, - mime, - ) - .await - .map(Some) + let public_url = upload_bytes(dctx, &storage_path, &bytes, mime).await?; + + Ok(Some(UploadedAsset { + storage_path, + public_url, + })) } async fn insert_news_row( @@ -659,6 +915,36 @@ async fn ensure_artist_link( Ok(()) } +async fn update_row( + dctx: &DeployContext, + table: &str, + id: i64, + payload: serde_json::Map, +) -> Result<(), CiteError> { + let url = format!("{}/rest/v1/{table}?id=eq.{id}", dctx.base_url); + let response = with_auth(dctx.client.patch(&url), &dctx.api_key, &dctx.bearer) + .header("Prefer", "return=representation") + .json(&payload) + .send() + .await?; + ensure_success(response, format!("Failed to update {table} {id}")).await?; + Ok(()) +} + +async fn update_news_thumbnail( + dctx: &DeployContext, + news_id: i64, + thumbnail: &str, +) -> Result<(), CiteError> { + update_row( + dctx, + "news", + news_id, + build_map(&[("thumbnail", Value::String(thumbnail.to_string()))]), + ) + .await +} + async fn ensure_metric_row(dctx: &DeployContext, news_id: i64) -> Result<(), CiteError> { insert_row( dctx, @@ -690,13 +976,28 @@ async fn insert_podcast_row( Ok(()) } -async fn resolve_plan_id(dctx: &DeployContext, tier_name: &str) -> Result { - lookup_row_id(dctx, "subscription_plans", "tier_name", tier_name) - .await? +async fn fetch_subscription_plan(dctx: &DeployContext) -> Result { + let url = format!( + "{}/rest/v1/user_subscriptions?select=subscription_plan_id&limit=1", + dctx.base_url + ); + let resp = with_auth(dctx.client.get(&url), &dctx.api_key, &dctx.bearer) + .send() + .await?; + if !resp.status().is_success() { + return Err(CiteError::Deploy(format!( + "Failed to fetch subscription: HTTP {}", + resp.status() + ))); + } + let rows: Vec = resp.json().await?; + rows.first() + .and_then(|r| r.get("subscription_plan_id").and_then(|v| v.as_i64())) .ok_or_else(|| { - CiteError::Deploy(format!( - "Subscription plan '{tier_name}' not found in the database. Have your infra team create it before deploying." - )) + CiteError::Deploy( + "No active subscription found for this account. Subscribe before deploying." + .to_string(), + ) }) } @@ -736,30 +1037,24 @@ fn word_count(content: &str) -> i64 { } async fn upload_bytes( - client: &reqwest::Client, - base_url: &str, - service_key: &str, + dctx: &DeployContext, storage_path: &str, bytes: &[u8], mime: &str, ) -> Result { + let base_url = &dctx.base_url; let url = format!("{base_url}/storage/v1/object/{STORAGE_BUCKET}/{storage_path}"); - let response = with_auth(client.post(&url), service_key) + let response = with_auth(dctx.client.post(&url), &dctx.api_key, &dctx.bearer) .header("Content-Type", mime) .body(bytes.to_vec()) .send() .await?; - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - return Err(CiteError::Deploy(format!( - "Failed to upload {storage_path}: HTTP {status} - {body}" - ))); - } + ensure_success(response, format!("Failed to upload {storage_path}")).await?; let public_url = format!("{base_url}/storage/v1/object/public/{STORAGE_BUCKET}/{storage_path}"); + info!("Uploaded {storage_path}"); Ok(public_url) } @@ -781,47 +1076,38 @@ fn mime_for_extension(ext: &str) -> &'static str { #[cfg(test)] mod tests { use super::*; - use crate::manifest::BackendConfig; fn backend(url: &str, key: &str) -> BackendConfig { BackendConfig { - staging_url: url.to_string(), - staging_service_key: key.to_string(), - subscription_plan: "Basic".into(), + staging_url: Some(url.to_string()), + staging_service_key: Some(key.to_string()), } } #[test] - fn test_resolve_service_key_env_wins() { - unsafe { - std::env::set_var("CITE_STAGING_SERVICE_KEY", "from-env"); - } + fn test_resolve_bearer_uses_inline_when_no_session() { assert_eq!( - resolve_service_key(&backend("https://x.supabase.co", "inline")).unwrap(), - "from-env" + resolve_bearer(&backend("https://x.supabase.co", "inline"), None).unwrap(), + "inline" ); - unsafe { - std::env::remove_var("CITE_STAGING_SERVICE_KEY"); - } } #[test] - fn test_resolve_service_key_falls_back_to_inline() { - unsafe { - std::env::remove_var("CITE_STAGING_SERVICE_KEY"); - } + fn test_resolve_bearer_prefers_session() { + let session = Session { + access_token: "user-jwt".into(), + refresh_token: "refresh".into(), + email: "a@b.c".into(), + }; assert_eq!( - resolve_service_key(&backend("https://x.supabase.co", "inline")).unwrap(), - "inline" + resolve_bearer(&backend("https://x.supabase.co", "inline"), Some(&session)).unwrap(), + "user-jwt" ); } #[test] - fn test_resolve_service_key_errors_when_empty() { - unsafe { - std::env::remove_var("CITE_STAGING_SERVICE_KEY"); - } - assert!(resolve_service_key(&backend("https://x.supabase.co", "")).is_err()); + fn test_resolve_bearer_errors_when_empty() { + assert!(resolve_bearer(&backend("https://x.supabase.co", ""), None).is_err()); } #[test] @@ -856,38 +1142,18 @@ mod tests { assert_eq!(word_count("one two three"), 3); assert_eq!(word_count(""), 0); } - - #[test] - fn test_build_map() { - let m = build_map(&[("a", Value::String("x".into()))]); - assert_eq!(m.get("a").unwrap(), "x"); - } - - #[test] - fn test_extract_id_from_array() { - let v = serde_json::json!([{ "id": 42 }]); - assert_eq!(extract_id(&v), Some(42)); - } - - #[test] - fn test_now_rfc3339_format() { - assert!(now_rfc3339().contains('T')); - } - - #[test] - fn test_encode_url_escapes_special_chars() { - assert!(encode_url("a b/c").contains('%')); - } } #[cfg(test)] mod http_tests { use super::*; - use crate::compiler; - use crate::manifest::{ - BackendConfig, BuildConfig, CompilerConfig, Manifest, ProjectConfig, ValidationConfig, + use crate::core::compiler; + use crate::core::manifest::{ + AssetsConfig, BackendConfig, BuildConfig, CompilerConfig, Manifest, ProjectConfig, + ValidationConfig, }; - use crate::project::ProjectContext; + use crate::core::project::ProjectContext; + use httpmock::Method::PATCH; use httpmock::prelude::*; use std::path::Path; @@ -901,12 +1167,11 @@ mod http_tests { }, build: BuildConfig::default(), backend: Some(BackendConfig { - staging_url: staging_url.into(), - staging_service_key: "key".into(), - subscription_plan: "Basic".into(), + staging_url: Some(staging_url.into()), + staging_service_key: Some("key".into()), }), compiler: CompilerConfig::default(), - assets: crate::manifest::AssetsConfig::default(), + assets: AssetsConfig::default(), validation: ValidationConfig::default(), }; std::fs::write(dir.join("cite.toml"), toml::to_string(&manifest).unwrap()).unwrap(); @@ -919,7 +1184,7 @@ podcasts: file: content/episode.md source_url: "https://example.com/episode" category: tech - thumbnail: assets/images/cover.png + thumbnail: assets/image/cover.png audio: assets/audio/episode.mp3 citation: content/papers.bib "#, @@ -945,8 +1210,8 @@ podcasts: "#, ) .unwrap(); - std::fs::create_dir_all(dir.join("assets/images")).unwrap(); - std::fs::write(dir.join("assets/images/cover.png"), "png").unwrap(); + std::fs::create_dir_all(dir.join("assets/image")).unwrap(); + std::fs::write(dir.join("assets/image/cover.png"), "png").unwrap(); std::fs::create_dir_all(dir.join("assets/audio")).unwrap(); std::fs::write(dir.join("assets/audio/episode.mp3"), "mp3").unwrap(); } @@ -968,6 +1233,10 @@ podcasts: w.method(POST).path("/rest/v1/news"); t.status(200).json_body(serde_json::json!([{ "id": 1 }])); }); + let news_patch = server.mock(|w, t| { + w.method(PATCH).path("/rest/v1/news"); + t.status(200).json_body(serde_json::json!([{ "id": 1 }])); + }); let artists_news = server.mock(|w, t| { w.method(POST).path("/rest/v1/artists_news"); t.status(200).json_body(serde_json::json!([{ "id": 1 }])); @@ -989,8 +1258,9 @@ podcasts: t.status(200).json_body(serde_json::json!([{ "id": 1 }])); }); let plans_get = server.mock(|w, t| { - w.method(GET).path("/rest/v1/subscription_plans"); - t.status(200).json_body(serde_json::json!([{ "id": 1 }])); + w.method(GET).path("/rest/v1/user_subscriptions"); + t.status(200) + .json_body(serde_json::json!([{ "subscription_plan_id": 3 }])); }); let podcasts = server.mock(|w, t| { w.method(POST).path("/rest/v1/podcasts"); @@ -1038,6 +1308,7 @@ podcasts: deploy(&ctx, false).await.expect("deploy should succeed"); assert_eq!(news.hits(), 1, "one news row"); + assert_eq!(news_patch.hits(), 1, "thumbnail patched after upload"); assert_eq!(artists_news.hits(), 1, "artist link"); assert_eq!(metric.hits(), 1, "metric row"); assert_eq!(categories.hits(), 1, "category created"); @@ -1045,7 +1316,7 @@ podcasts: assert_eq!(domains.hits(), 1, "domain from source url"); assert!( plans_get.hits() >= 1, - "subscription plan resolved from database" + "subscription plan fetched from user_subscriptions" ); assert_eq!(podcasts.hits(), 1, "podcast row"); assert_eq!(timeline.hits(), 1, "timeline from citation"); diff --git a/src/core/doctor.rs b/src/core/doctor.rs new file mode 100644 index 0000000..c64156f --- /dev/null +++ b/src/core/doctor.rs @@ -0,0 +1,319 @@ +use std::path::Path; +use tracing::{info, instrument, warn}; + +use crate::core::project::ProjectContext; +use crate::core::{CiteError, Style, styled}; + +pub enum DoctorOutcome { + Clean, + Findings { + errors: Vec, + warnings: Vec, + }, +} + +impl DoctorOutcome { + pub fn has_errors(&self) -> bool { + match self { + DoctorOutcome::Findings { errors, .. } => !errors.is_empty(), + _ => false, + } + } + + pub fn has_warnings(&self) -> bool { + match self { + DoctorOutcome::Findings { warnings, .. } => !warnings.is_empty(), + _ => false, + } + } + + pub fn merge(&mut self, other: DoctorOutcome) { + match other { + DoctorOutcome::Clean => {} + DoctorOutcome::Findings { + errors: new_errors, + warnings: new_warnings, + } => { + for e in new_errors { + self.push_error(e); + } + for w in new_warnings { + self.push_warning(w); + } + } + } + } + + fn push_error(&mut self, msg: String) { + match self { + DoctorOutcome::Findings { errors, .. } => errors.push(msg), + DoctorOutcome::Clean => { + *self = DoctorOutcome::Findings { + errors: vec![msg], + warnings: Vec::new(), + } + } + } + } + + fn push_warning(&mut self, msg: String) { + match self { + DoctorOutcome::Findings { warnings, .. } => warnings.push(msg), + DoctorOutcome::Clean => { + *self = DoctorOutcome::Findings { + errors: Vec::new(), + warnings: vec![msg], + } + } + } + } + + pub fn print(&self) { + for line in self.to_lines() { + eprintln!("{line}"); + } + } + + pub fn to_lines(&self) -> Vec { + match self { + DoctorOutcome::Clean => vec![], + DoctorOutcome::Findings { errors, warnings } => { + let mut lines = Vec::new(); + for e in errors { + lines.push(styled(format!("error: {e}"), Style::Error)); + } + for w in warnings { + lines.push(styled(format!("warning: {w}"), Style::Warning)); + } + lines.push(format!( + "{} {}", + styled(format!("{} error(s)", errors.len()), Style::Error), + styled(format!("{} warning(s)", warnings.len()), Style::Warning) + )); + lines + } + } + } +} + +fn collect_findings(errors: Vec, warnings: Vec) -> DoctorOutcome { + if errors.is_empty() && warnings.is_empty() { + DoctorOutcome::Clean + } else { + DoctorOutcome::Findings { errors, warnings } + } +} + +#[instrument(skip(ctx), fields(project = %ctx.manifest.project.name))] +pub fn validate_all(ctx: &ProjectContext) -> DoctorOutcome { + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + + validate_project_structure(ctx, &mut errors, &mut warnings); + validate_file_existence(ctx, &mut errors, &mut warnings); + validate_asset_formats(ctx, &mut warnings); + + collect_findings(errors, warnings) +} + +fn validate_project_structure( + ctx: &ProjectContext, + errors: &mut Vec, + warnings: &mut Vec, +) { + let required = [ + ("cite.toml", ctx.root.join("cite.toml")), + ( + &ctx.manifest.project.metadata_file, + ctx.root.join(&ctx.manifest.project.metadata_file), + ), + ]; + for (name, path) in &required { + if !path.exists() { + errors.push(format!( + "Required file '{name}' not found at {}", + path.display() + )); + } + } + + let dirs = [ + ("content", ctx.content_dir()), + ("assets/image", ctx.root.join("assets/image")), + ("assets/audio", ctx.root.join("assets/audio")), + ]; + for (name, path) in &dirs { + if !path.is_dir() { + warnings.push(format!( + "Directory '{name}' does not exist at {}", + path.display() + )); + } + } +} + +fn validate_file_existence( + ctx: &ProjectContext, + errors: &mut Vec, + warnings: &mut Vec, +) { + for pod in &ctx.metadata.podcasts { + let path = ctx.root.join(&pod.file); + if !path.exists() { + errors.push(format!( + "Podcast '{}' references file '{}' which does not exist", + pod.title, pod.file + )); + } + + if let Some(cit) = &pod.citation { + let cit_path = ctx.root.join(cit); + if !cit_path.exists() { + warnings.push(format!( + "Podcast '{}' references citation file '{}' which does not exist", + pod.title, cit + )); + } + } + + if let Some(audio) = &pod.audio { + let audio_path = ctx.root.join(audio); + if !audio_path.exists() { + errors.push(format!( + "Podcast '{}' references audio file '{}' which does not exist", + pod.title, audio + )); + } + } + } +} + +fn validate_asset_formats(ctx: &ProjectContext, warnings: &mut Vec) { + let allowed_audio: std::collections::HashSet<&str> = ctx + .manifest + .assets + .audio_formats + .iter() + .map(|s| s.as_str()) + .collect(); + + for pod in &ctx.metadata.podcasts { + if let Some(audio) = &pod.audio { + let ext = Path::new(audio) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + if !allowed_audio.contains(ext) { + warnings.push(format!( + "Podcast '{}' has audio file '{audio}' with extension '{ext}' not in allowed audio formats {:?}", + pod.title, ctx.manifest.assets.audio_formats + )); + } + } + + let ext = Path::new(&pod.file) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + if !matches!(ext, "md" | "rst") { + warnings.push(format!( + "Podcast '{}' has file '{}' with unexpected extension '{ext}'", + pod.title, pod.file + )); + } + } +} + +#[instrument(skip(ctx), fields(project = %ctx.manifest.project.name))] +pub fn lint_all(ctx: &ProjectContext) -> DoctorOutcome { + let mut warnings = Vec::new(); + + for pod in &ctx.metadata.podcasts { + let path = ctx.root.join(&pod.file); + if path.exists() + && let Ok(content) = std::fs::read_to_string(&path) + { + let word_count = content.split_whitespace().count(); + if word_count < 10 { + warnings.push(format!( + "Podcast '{}' is very short ({} words)", + pod.title, word_count + )); + } + } + } + + collect_findings(Vec::new(), warnings) +} + +pub fn check_file(root: &Path, filename: &str, hint: &str) { + let path = root.join(filename); + if path.exists() { + info!("{filename} found"); + } else if hint.is_empty() { + warn!("{filename} not found"); + } else { + warn!("{filename} not found - {hint}"); + } +} + +pub fn run(ctx: &ProjectContext) -> Result { + info!("Running diagnostics"); + + let mut outcome = DoctorOutcome::Clean; + outcome.merge(validate_all(ctx)); + outcome.merge(lint_all(ctx)); + + let meta_file = &ctx.manifest.project.metadata_file; + check_file(&ctx.root, "cite.toml", "run 'cite-cli init'"); + check_file(&ctx.root, meta_file, ""); + for dir in &["content", "assets/audio", "assets/image", "build"] { + let d = ctx.root.join(dir); + if d.is_dir() { + info!("{dir}/ exists"); + } else if *dir == "build" { + info!("{dir}/ missing (created by build)"); + } else { + info!("{dir}/ missing (will be created on init)"); + } + } + + if ctx + .manifest + .backend + .as_ref() + .and_then(|b| b.staging_url.as_deref()) + .is_some_and(|s| !s.is_empty()) + { + info!("Backend configured for staging"); + } else { + let msg = "No backend configured (deploy will fail)".to_string(); + warn!("{msg}"); + outcome.push_warning(msg); + } + if ctx.manifest.build.incremental { + info!("Incremental builds enabled"); + } + if ctx.manifest.project.artist_id.is_empty() { + let msg = "Artist ID is empty - set it in [project] in cite.toml".to_string(); + warn!("{msg}"); + outcome.push_warning(msg); + } else { + info!("Artist ID: {}", ctx.manifest.project.artist_id); + } + if ctx + .manifest + .backend + .as_ref() + .and_then(|b| b.staging_service_key.as_deref()) + .is_some_and(|s| !s.is_empty()) + { + info!("Using inline staging_service_key from cite.toml"); + } else { + let msg = "No staging service key found - deploy will fail".to_string(); + warn!("{msg}"); + outcome.push_warning(msg); + } + + Ok(outcome) +} diff --git a/src/manifest.rs b/src/core/manifest.rs similarity index 61% rename from src/manifest.rs rename to src/core/manifest.rs index 490b32c..704f355 100644 --- a/src/manifest.rs +++ b/src/core/manifest.rs @@ -1,42 +1,11 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Manifest { - #[serde(default)] - pub project: ProjectConfig, - #[serde(default)] - pub build: BuildConfig, - #[serde(default)] - pub backend: Option, - #[serde(default)] - pub compiler: CompilerConfig, - #[serde(default)] - pub assets: AssetsConfig, - #[serde(default)] - pub validation: ValidationConfig, -} - -fn get_language() -> String { - "en".to_string() -} - -fn get_metadata_file() -> String { - "metadata.yml".to_string() -} - -fn get_uuid() -> String { - uuid::Uuid::new_v4().to_string() -} - -#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] pub struct ProjectConfig { - #[serde(default)] pub name: String, - #[serde(default = "get_language")] pub language: String, - #[serde(default = "get_metadata_file")] pub metadata_file: String, - #[serde(default = "get_uuid")] pub artist_id: String, } @@ -44,116 +13,110 @@ impl Default for ProjectConfig { fn default() -> Self { Self { name: String::new(), - language: get_language(), - metadata_file: get_metadata_file(), - artist_id: get_uuid(), + language: "en".to_string(), + metadata_file: "metadata.yml".to_string(), + artist_id: String::new(), } } } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] pub struct BuildConfig { - #[serde(default = "get_compiler_version")] pub compiler_version: f64, - #[serde(default = "get_incremental")] pub incremental: bool, - #[serde(default = "get_output_format")] pub output_format: String, } -fn get_compiler_version() -> f64 { - 1.0 -} - -fn get_incremental() -> bool { - true -} - -fn get_output_format() -> String { - "json".into() -} - impl Default for BuildConfig { fn default() -> Self { Self { - compiler_version: get_compiler_version(), - incremental: get_incremental(), - output_format: get_output_format(), + compiler_version: 1.0, + incremental: true, + output_format: "json".to_string(), } } } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] pub struct BackendConfig { - #[serde(default)] - pub staging_url: String, - #[serde(default)] - pub staging_service_key: String, - #[serde(default = "get_subscription_plan")] - pub subscription_plan: String, + pub staging_url: Option, + pub staging_service_key: Option, } -fn get_subscription_plan() -> String { - "Basic".into() +impl Default for BackendConfig { + fn default() -> Self { + Self { + staging_url: None, + staging_service_key: None, + } + } } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] pub struct CompilerConfig { - #[serde(default = "get_enabled_extensions")] pub enabled_extensions: Vec, } -fn get_enabled_extensions() -> Vec { - vec!["tables".into(), "footnotes".into()] -} - impl Default for CompilerConfig { fn default() -> Self { Self { - enabled_extensions: get_enabled_extensions(), + enabled_extensions: vec!["tables".to_string(), "footnotes".to_string()], } } } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] pub struct AssetsConfig { - #[serde(default = "get_audio_formats")] pub audio_formats: Vec, - #[serde(default = "get_image_formats")] pub image_formats: Vec, } -fn get_audio_formats() -> Vec { - vec!["mp3".into(), "wav".into(), "m4a".into()] -} - -fn get_image_formats() -> Vec { - vec!["jpg".into(), "png".into()] -} - impl Default for AssetsConfig { fn default() -> Self { Self { - audio_formats: get_audio_formats(), - image_formats: get_image_formats(), + audio_formats: vec!["mp3".to_string(), "wav".to_string(), "m4a".to_string()], + image_formats: vec!["jpg".to_string(), "png".to_string()], } } } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] pub struct ValidationConfig { - #[serde(default = "get_strict")] pub strict: bool, } -fn get_strict() -> bool { - true +impl Default for ValidationConfig { + fn default() -> Self { + Self { strict: true } + } } -impl Default for ValidationConfig { +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct Manifest { + pub project: ProjectConfig, + pub build: BuildConfig, + #[serde(skip_serializing_if = "Option::is_none")] + pub backend: Option, + pub compiler: CompilerConfig, + pub assets: AssetsConfig, + pub validation: ValidationConfig, +} + +impl Default for Manifest { fn default() -> Self { Self { - strict: get_strict(), + project: ProjectConfig::default(), + build: BuildConfig::default(), + backend: None, + compiler: CompilerConfig::default(), + assets: AssetsConfig::default(), + validation: ValidationConfig::default(), } } } @@ -188,7 +151,7 @@ mod tests { assert!(m.build.incremental); assert!(m.backend.is_none()); assert!(m.validation.strict); - assert!(!m.project.artist_id.is_empty()); + assert!(m.project.artist_id.is_empty()); } #[test] @@ -232,8 +195,29 @@ strict = true assert_eq!(m.assets.image_formats, vec!["jpg"]); assert!(m.validation.strict); assert_eq!( - m.backend.as_ref().unwrap().staging_url, - "https://example.com" + m.backend.as_ref().unwrap().staging_url.as_deref(), + Some("https://example.com") ); } + + #[test] + fn test_deserialize_partial_applies_defaults() { + let toml_str = r#" +[project] +name = "partial" +artist_id = "abc" +"#; + let m: Manifest = toml::from_str(toml_str).unwrap(); + assert_eq!(m.project.name, "partial"); + assert_eq!(m.project.language, "en"); + assert_eq!(m.project.metadata_file, "metadata.yml"); + assert_eq!(m.project.artist_id, "abc"); + assert_eq!(m.build.compiler_version, 1.0); + assert!(m.build.incremental); + assert_eq!(m.build.output_format, "json"); + assert_eq!(m.compiler.enabled_extensions, vec!["tables", "footnotes"]); + assert_eq!(m.assets.audio_formats, vec!["mp3", "wav", "m4a"]); + assert_eq!(m.assets.image_formats, vec!["jpg", "png"]); + assert!(m.validation.strict); + } } diff --git a/src/metadata.rs b/src/core/metadata.rs similarity index 58% rename from src/metadata.rs rename to src/core/metadata.rs index e5101f0..6f2ab74 100644 --- a/src/metadata.rs +++ b/src/core/metadata.rs @@ -2,35 +2,30 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] pub struct Podcast { - #[serde(default = "get_uuid")] - pub id: String, - #[serde(default)] pub title: String, - #[serde(default)] pub file: String, - #[serde(default)] + + #[serde(skip_serializing_if = "Option::is_none")] pub source_url: Option, - #[serde(default)] + + #[serde(skip_serializing_if = "Option::is_none")] pub category: Option, - #[serde(default)] + + #[serde(skip_serializing_if = "Option::is_none")] pub thumbnail: Option, - #[serde(default)] + + #[serde(skip_serializing_if = "Option::is_none")] pub audio: Option, - #[serde(default)] - pub citation: Option, - #[serde(default)] - pub content: Option, -} -fn get_uuid() -> String { - Uuid::new_v4().to_string() + #[serde(skip_serializing_if = "Option::is_none")] + pub citation: Option, } impl Default for Podcast { fn default() -> Self { Self { - id: get_uuid(), title: String::new(), file: String::new(), source_url: None, @@ -38,30 +33,29 @@ impl Default for Podcast { thumbnail: None, audio: None, citation: None, - content: None, } } } +fn get_uuid() -> String { + Uuid::new_v4().to_string() +} + #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] pub struct TimelineEntry { - #[serde(default)] - pub date: String, - #[serde(default = "get_uuid")] pub id: String, - #[serde(default)] + pub date: Option, pub title: String, - #[serde(default)] pub summary: Option, - #[serde(default)] pub url: Option, } impl Default for TimelineEntry { fn default() -> Self { Self { - date: String::new(), - id: Uuid::new_v4().to_string(), + id: get_uuid(), + date: None, title: String::new(), summary: None, url: None, @@ -70,60 +64,19 @@ impl Default for TimelineEntry { } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Timeline { - #[serde(default = "get_uuid")] - pub id: String, - #[serde(default)] - pub entries: Vec, -} - -impl Default for Timeline { - fn default() -> Self { - Self { - id: get_uuid(), - entries: vec![TimelineEntry::default()], - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ContentBundle { - #[serde(default = "get_compiler_version")] - pub compiler_version: f64, - #[serde(default)] - pub project: String, - #[serde(default = "get_uuid")] - pub artist_id: String, - #[serde(default)] +#[serde(default)] +pub struct Metadata { pub podcasts: Vec, - #[serde(default)] - pub timelines: Vec, -} - -fn get_compiler_version() -> f64 { - 0.0 } -impl Default for ContentBundle { +impl Default for Metadata { fn default() -> Self { - Self { - compiler_version: get_compiler_version(), - project: String::new(), - artist_id: get_uuid(), - podcasts: vec![Podcast::default()], - timelines: vec![Timeline::default()], - } + Self { podcasts: vec![] } } } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct Metadata { - #[serde(default)] - pub podcasts: Vec, -} - impl Metadata { - pub fn content_files(&self) -> Vec { + pub fn referenced_files(&self) -> Vec { let mut files = Vec::new(); for p in &self.podcasts { files.push(p.file.clone()); @@ -146,15 +99,13 @@ mod tests { fn test_yaml_parse() { let yaml = r#" podcasts: - - id: "abc" - title: "Test Podcast" + - title: "Test Podcast" file: content/test.md source_url: "https://example.com" category: "tech" "#; let meta: Metadata = serde_yaml::from_str(yaml).unwrap(); assert_eq!(meta.podcasts.len(), 1); - assert_eq!(meta.podcasts[0].id, "abc"); assert_eq!(meta.podcasts[0].title, "Test Podcast"); assert_eq!(meta.podcasts[0].file, "content/test.md"); assert_eq!( @@ -164,11 +115,9 @@ podcasts: } #[test] - fn test_content_files_includes_all() { - let id = get_uuid(); + fn test_referenced_files_includes_all() { let meta = Metadata { podcasts: vec![Podcast { - id: id.clone(), title: "P".into(), file: "content/p.md".into(), source_url: None, @@ -176,12 +125,11 @@ podcasts: thumbnail: None, audio: Some("assets/audio/p.mp3".into()), citation: Some("content/p.bib".into()), - content: None, + ..Default::default() }], }; - assert_eq!(meta.podcasts[0].id, id.clone()); - let files = meta.content_files(); + let files = meta.referenced_files(); assert_eq!(files.len(), 3); assert!(files.contains(&"content/p.md".to_string())); assert!(files.contains(&"content/p.bib".to_string())); diff --git a/src/core/mod.rs b/src/core/mod.rs new file mode 100644 index 0000000..ef3cdaf --- /dev/null +++ b/src/core/mod.rs @@ -0,0 +1,13 @@ +pub mod cache; +pub mod compiler; +pub mod deploy; +pub mod doctor; +pub mod manifest; +pub mod metadata; +pub mod project; +pub mod scaffold; +pub mod uninstall; +pub mod upgrade; + +pub mod report; +pub use report::{CiteError, Style, styled}; diff --git a/src/project.rs b/src/core/project.rs similarity index 60% rename from src/project.rs rename to src/core/project.rs index 54d3648..f832073 100644 --- a/src/project.rs +++ b/src/core/project.rs @@ -1,7 +1,8 @@ -use crate::error::CiteError; -use crate::manifest::Manifest; -use crate::metadata::Metadata; use std::path::{Path, PathBuf}; + +use crate::core::CiteError; +use crate::core::manifest::Manifest; +use crate::core::metadata::Metadata; use tracing::instrument; #[derive(Debug, Clone)] @@ -53,7 +54,7 @@ impl ProjectContext { pub fn content_files(&self) -> Vec { self.metadata - .content_files() + .referenced_files() .iter() .map(|f| self.root.join(f)) .collect() @@ -72,36 +73,22 @@ impl ProjectContext { } } -#[cfg(test)] -mod tests { - use super::*; - use crate::manifest::Manifest; - use std::path::PathBuf; +pub fn discover_projects(root: &Path) -> Vec { + let mut projects = Vec::new(); - #[test] - fn test_content_files_collects_all_references() { - let ctx = ProjectContext { - root: PathBuf::from("/root"), - manifest: Manifest::default_template("test"), - metadata: Metadata { - podcasts: vec![crate::metadata::Podcast { - id: "abc".into(), - title: "P".into(), - file: "content/p.md".into(), - source_url: None, - category: None, - thumbnail: None, - audio: Some("assets/audio/p.mp3".into()), - citation: Some("content/p.bib".into()), - content: None, - }], - }, - }; + if root.join("cite.toml").exists() { + projects.push(root.to_path_buf()); + } - let files = ctx.content_files(); - assert_eq!(files.len(), 3); - assert!(files.contains(&PathBuf::from("/root/content/p.md"))); - assert!(files.contains(&PathBuf::from("/root/content/p.bib"))); - assert!(files.contains(&PathBuf::from("/root/assets/audio/p.mp3"))); + if let Ok(entries) = std::fs::read_dir(root) { + for entry in entries.flatten() { + let p = entry.path(); + if p.is_dir() && p != root && p.join("cite.toml").exists() { + projects.push(p); + } + } } + + projects.sort(); + projects } diff --git a/src/core/report.rs b/src/core/report.rs new file mode 100644 index 0000000..ccb2969 --- /dev/null +++ b/src/core/report.rs @@ -0,0 +1,60 @@ +use colored::Colorize; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum CiteError { + #[error("{0}")] + Io(#[from] std::io::Error), + + #[error("{0}")] + Config(String), + + #[error("{0}")] + Parse(String), + + #[error("{0}")] + Auth(String), + + #[error("{0}")] + Deploy(String), + + #[error("{0}")] + Network(#[from] reqwest::Error), +} + +impl From for CiteError { + fn from(e: serde_yaml::Error) -> Self { + CiteError::Parse(format!("YAML parse error: {}", e)) + } +} + +impl From for CiteError { + fn from(e: toml::de::Error) -> Self { + CiteError::Parse(format!("TOML parse error: {}", e)) + } +} + +impl From for CiteError { + fn from(e: serde_json::Error) -> Self { + CiteError::Parse(format!("JSON error: {}", e)) + } +} + +pub enum Style { + Success, + Error, + Warning, + Info, + Header, +} + +pub fn styled(msg: impl AsRef, style: Style) -> String { + let s = msg.as_ref(); + match style { + Style::Success => format!(" {}", s.green().bold()), + Style::Error => format!(" {}", s.red().bold()), + Style::Warning => format!(" {}", s.yellow().bold()), + Style::Info => format!(" {}", s.cyan()), + Style::Header => s.bold().underline().to_string(), + } +} diff --git a/src/core/scaffold.rs b/src/core/scaffold.rs new file mode 100644 index 0000000..8f70ee8 --- /dev/null +++ b/src/core/scaffold.rs @@ -0,0 +1,119 @@ +use std::fs; +use std::path::Path; +use tracing::instrument; + +use crate::core::CiteError; +use crate::core::manifest::Manifest; +use crate::core::metadata::Metadata; + +const AUTO_GEN_HEADER: &str = r#"# This file was autogenerated by cite-cli init +# https://github.com/VinayIN/cite-cli +# +# Copyright 2026, Binay Pradhan +# SPDX-License-Identifier: MIT + +"#; + +pub struct InitReport { + pub directories_created: Vec, + pub files_created: Vec, + pub files_skipped: Vec, +} + +impl InitReport { + pub fn to_lines(&self) -> Vec { + let mut lines = Vec::new(); + for d in &self.directories_created { + lines.push(format!("Created directory: {d}")); + } + for f in &self.files_created { + lines.push(format!("Created file: {f}")); + } + for f in &self.files_skipped { + lines.push(format!("Skipped: {f}")); + } + lines + } +} + +#[instrument(skip(root), fields(project = %name))] +pub fn init_project(name: &str, root: &Path) -> Result { + let mut report = InitReport { + directories_created: vec![], + files_created: vec![], + files_skipped: vec![], + }; + + if !root.exists() { + fs::create_dir_all(root)?; + report + .directories_created + .push(root.to_string_lossy().to_string()); + } + + for sub in ["content", "assets/audio", "assets/image"] { + let path = root.join(sub); + if !path.exists() { + fs::create_dir_all(&path)?; + report.directories_created.push(sub.to_string()); + } + } + + let manifest_path = root.join("cite.toml"); + if !manifest_path.exists() { + fs::write(&manifest_path, manifest_template(name)?)?; + report.files_created.push("cite.toml".to_string()); + } else { + report + .files_skipped + .push("cite.toml (already exists)".to_string()); + } + + let meta_path = root.join("metadata.yml"); + if !meta_path.exists() { + fs::write(&meta_path, metadata_template()?)?; + report.files_created.push("metadata.yml".to_string()); + } else { + report + .files_skipped + .push("metadata.yml (already exists)".to_string()); + } + + let gitignore_path = root.join(".gitignore"); + if !gitignore_path.exists() { + fs::write( + &gitignore_path, + format!("{AUTO_GEN_HEADER}\nbuild/\n.cite-cache.json\n"), + )?; + report.files_created.push(".gitignore".to_string()); + } else { + report + .files_skipped + .push(".gitignore (already exists)".to_string()); + } + + Ok(report) +} + +fn manifest_template(name: &str) -> Result { + let manifest = Manifest::default_template(name); + let body = toml::to_string(&manifest).map_err(|e| CiteError::Config(e.to_string()))?; + Ok(format!("{AUTO_GEN_HEADER}\n{body}")) +} + +fn metadata_template() -> Result { + let meta = Metadata::default(); + let body = serde_yaml::to_string(&meta)?; + Ok(format!( + "{AUTO_GEN_HEADER}# Add your podcast entries below. Each entry becomes one episode.\n\ +# title: Display title of the episode\n\ +# file: Path to the Markdown source\n\ +# source_url: Original URL (optional)\n\ +# category: Category name (optional)\n\ +# thumbnail: Path under assets/image (optional)\n\ +# audio: Path under assets/audio (optional)\n\ +# citation: Path to BibTeX file (optional)\n\ +{}\n", + body.trim_end(), + )) +} diff --git a/src/uninstall.rs b/src/core/uninstall.rs similarity index 66% rename from src/uninstall.rs rename to src/core/uninstall.rs index 873bb68..01779c6 100644 --- a/src/uninstall.rs +++ b/src/core/uninstall.rs @@ -1,8 +1,7 @@ use std::io::Write; +use tracing::info; -use colored::Colorize; - -use crate::error::CiteError; +use crate::core::{CiteError, Style, styled}; pub fn uninstall(force: bool) -> Result<(), CiteError> { let current_exe = std::env::current_exe() @@ -12,10 +11,7 @@ pub fn uninstall(force: bool) -> Result<(), CiteError> { .parent() .ok_or_else(|| CiteError::Config("Cannot determine install directory".into()))?; - eprintln!( - "{}", - format!("cite-cli is installed at: {}", current_exe.display()).bold() - ); + info!("cite-cli installed at: {}", current_exe.display()); if !force { eprintln!(); @@ -29,17 +25,14 @@ pub fn uninstall(force: bool) -> Result<(), CiteError> { match input.trim().to_lowercase().as_str() { "y" | "yes" => {} _ => { - eprintln!("{}", "Uninstall cancelled".red().bold()); + eprintln!("{}", styled("Uninstall cancelled", Style::Error)); return Ok(()); } } } std::fs::remove_file(¤t_exe)?; - eprintln!( - "{}", - format!("Removed {}", current_exe.display()).green().bold() - ); + info!("Removed {}", current_exe.display()); if install_dir .read_dir() @@ -47,12 +40,7 @@ pub fn uninstall(force: bool) -> Result<(), CiteError> { .unwrap_or(false) { let _ = std::fs::remove_dir(install_dir); - eprintln!( - "{}", - format!("Removed empty directory {}", install_dir.display()) - .green() - .bold() - ); + info!("Removed empty directory {}", install_dir.display()); } let home = std::env::var("HOME").unwrap_or_else(|_| "~".into()); @@ -72,18 +60,16 @@ pub fn uninstall(force: bool) -> Result<(), CiteError> { if found { eprintln!(); - eprintln!( - "{}", - "Shell config files contain PATH references to the install directory.".cyan() - ); - eprintln!( - " Edit your shell config (~/.zshrc, ~/.bashrc, etc.) and remove lines containing:" - ); - eprintln!(" {}", install_dir_str); + info!("Shell config files reference the install directory"); + eprintln!(" Edit ~/.zshrc, ~/.bashrc, etc. and remove lines containing:"); + eprintln!(" {install_dir_str}"); eprintln!(" Then restart your shell or run: source ~/.zshrc"); } eprintln!(); - eprintln!("{}", "cite-cli has been uninstalled.".green().bold()); + eprintln!( + "{}", + styled("cite-cli has been uninstalled.", Style::Success) + ); Ok(()) } diff --git a/src/upgrade.rs b/src/core/upgrade.rs similarity index 83% rename from src/upgrade.rs rename to src/core/upgrade.rs index a341fc7..6ced1df 100644 --- a/src/upgrade.rs +++ b/src/core/upgrade.rs @@ -1,15 +1,15 @@ -use colored::Colorize; +use tracing::{info, warn}; -use crate::error::CiteError; +use crate::core::{CiteError, Style, styled}; const REPO: &str = "VinayIN/cite-cli"; const BIN_NAME: &str = "cite-cli"; -pub async fn upgrade() -> Result<(), CiteError> { +pub async fn upgrade() -> Result { let current_exe = std::env::current_exe() .map_err(|e| CiteError::Config(format!("Cannot determine executable path: {e}")))?; - eprintln!("{}", "Checking for updates".bold()); + info!("Checking for updates"); let client = reqwest::Client::new(); let api_url = format!("https://api.github.com/repos/{REPO}/releases/latest"); @@ -36,36 +36,26 @@ pub async fn upgrade() -> Result<(), CiteError> { let current = env!("CARGO_PKG_VERSION"); if current == latest { - eprintln!( - "{}", - format!("Already up to date (v{current})").green().bold() - ); - return Ok(()); + return Ok(styled( + format!("Already up to date (v{current})"), + Style::Success, + )); } if !is_newer(latest, current) { - eprintln!( - "{}", - format!("Local version v{current} is newer than remote v{latest}").cyan() - ); - return Ok(()); + return Ok(styled( + format!("Local version v{current} is newer than remote v{latest}"), + Style::Info, + )); } - eprintln!( - "{}", - format!("New version available: v{latest} (current: v{current})") - .yellow() - .bold() - ); + warn!("New version available: v{latest} (current: v{current})"); let target = target_triple(); let download_url = format!("https://github.com/{REPO}/releases/download/v{latest}/{BIN_NAME}-{target}"); - eprintln!( - "{}", - format!("Downloading {BIN_NAME} v{latest} for {target}").bold() - ); + info!("Downloading {BIN_NAME} v{latest} for {target}"); let tmp_path = { let mut p = current_exe.clone(); @@ -93,8 +83,7 @@ pub async fn upgrade() -> Result<(), CiteError> { std::fs::rename(&tmp_path, ¤t_exe)?; - eprintln!("{}", format!("Updated to v{latest}").green().bold()); - Ok(()) + Ok(styled(format!("Updated to v{latest}"), Style::Success)) } fn target_triple() -> String { diff --git a/src/error.rs b/src/error.rs deleted file mode 100644 index 9718918..0000000 --- a/src/error.rs +++ /dev/null @@ -1,103 +0,0 @@ -use colored::Colorize; -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum CiteError { - #[error("IO error: {0}")] - Io(#[from] std::io::Error), - - #[error("Config error: {0}")] - Config(String), - - #[error("Parse error: {0}")] - Parse(String), - - #[error("Validation error: {0}")] - Validation(String), - - #[error("Deploy error: {0}")] - Deploy(String), - - #[error("Network error: {0}")] - Network(#[from] reqwest::Error), -} - -impl From for CiteError { - fn from(e: serde_yaml::Error) -> Self { - CiteError::Parse(format!("YAML parse error: {}", e)) - } -} - -impl From for CiteError { - fn from(e: toml::de::Error) -> Self { - CiteError::Parse(format!("TOML parse error: {}", e)) - } -} - -impl From for CiteError { - fn from(e: serde_json::Error) -> Self { - CiteError::Parse(format!("JSON error: {}", e)) - } -} - -pub struct ValidationReport { - pub errors: Vec, - pub warnings: Vec, - pub infos: Vec, -} - -impl ValidationReport { - pub fn new() -> Self { - Self { - errors: vec![], - warnings: vec![], - infos: vec![], - } - } - - pub fn error>(&mut self, msg: S) { - self.errors.push(msg.into()); - } - - pub fn warning>(&mut self, msg: S) { - self.warnings.push(msg.into()); - } - - pub fn info>(&mut self, msg: S) { - self.infos.push(msg.into()); - } - - pub fn has_errors(&self) -> bool { - !self.errors.is_empty() - } - - pub fn print(&self) { - for e in &self.errors { - eprintln!("{}", format!(" {}", e).red().bold()); - } - for w in &self.warnings { - eprintln!("{}", format!(" {}", w).yellow().bold()); - } - for i in &self.infos { - eprintln!("{}", format!(" {}", i).cyan()); - } - - let summary = format!( - "{} error(s), {} warning(s), {} info(s)", - self.errors.len(), - self.warnings.len(), - self.infos.len() - ); - if self.has_errors() { - eprintln!("{}", summary.red().bold()); - } else if self.has_warnings() { - eprintln!("{}", summary.yellow().bold()); - } else { - eprintln!("{}", summary.green().bold()); - } - } - - pub fn has_warnings(&self) -> bool { - !self.warnings.is_empty() - } -} diff --git a/src/main.rs b/src/main.rs index 9cb65b2..e5eec16 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,15 +1,6 @@ -mod cache; mod cli; -mod compiler; -mod deploy; -mod error; -mod manifest; -mod metadata; -mod project; -mod scaffold; -mod uninstall; -mod upgrade; -mod validation; +mod core; +mod tui; use clap::Parser; use cli::Cli; @@ -32,12 +23,20 @@ async fn main() { .with_target(false) .with_level(true) .without_time() + .with_writer(std::io::stderr) .init(); info!("cite-cli v{}", env!("CARGO_PKG_VERSION")); - if let Err(e) = cli.command.execute().await { - eprintln!("{} {}", "error:".red().bold(), e); - std::process::exit(1); + if let Some(cmd) = cli.command { + if let Err(e) = cmd.execute().await { + eprintln!("{} {}", "error:".red().bold(), e); + std::process::exit(1); + } + } else { + if let Err(e) = tui::run_tui().await { + eprintln!("{} {}", "error:".red().bold(), e); + std::process::exit(1); + } } } diff --git a/src/scaffold.rs b/src/scaffold.rs deleted file mode 100644 index fc13ce1..0000000 --- a/src/scaffold.rs +++ /dev/null @@ -1,175 +0,0 @@ -use crate::error::CiteError; -use crate::manifest::Manifest; -use std::fs; -use std::path::Path; -use tracing::instrument; - -pub struct InitReport { - pub directories_created: Vec, - pub files_created: Vec, - pub files_skipped: Vec, -} - -#[instrument(skip(root), fields(project = %name))] -pub fn init_project(name: &str, root: &Path) -> Result { - let mut report = InitReport { - directories_created: vec![], - files_created: vec![], - files_skipped: vec![], - }; - - if !root.exists() { - fs::create_dir_all(root)?; - report - .directories_created - .push(root.to_string_lossy().to_string()); - } - - for sub in ["content", "assets/audio", "assets/images"] { - let path = root.join(sub); - if !path.exists() { - fs::create_dir_all(&path)?; - report.directories_created.push(sub.to_string()); - } - } - - let manifest_path = root.join("cite.toml"); - if !manifest_path.exists() { - fs::write(&manifest_path, manifest_template(name)?)?; - report.files_created.push("cite.toml".to_string()); - } else { - report - .files_skipped - .push("cite.toml (already exists)".to_string()); - } - - let meta_path = root.join("metadata.yml"); - if !meta_path.exists() { - fs::write(&meta_path, metadata_template())?; - report.files_created.push("metadata.yml".to_string()); - } else { - report - .files_skipped - .push("metadata.yml (already exists)".to_string()); - } - - let gitignore_path = root.join(".gitignore"); - if !gitignore_path.exists() { - fs::write(&gitignore_path, "build/\n.cite-cache.json\n")?; - report.files_created.push(".gitignore".to_string()); - } else { - report - .files_skipped - .push(".gitignore (already exists)".to_string()); - } - - Ok(report) -} - -fn manifest_template(name: &str) -> Result { - let manifest = Manifest::default_template(name); - let enabled_extensions = manifest - .compiler - .enabled_extensions - .iter() - .map(|ext| format!("\"{ext}\"")) - .collect::>() - .join(", "); - let audio_formats = manifest - .assets - .audio_formats - .iter() - .map(|ext| format!("\"{ext}\"")) - .collect::>() - .join(", "); - let image_formats = manifest - .assets - .image_formats - .iter() - .map(|ext| format!("\"{ext}\"")) - .collect::>() - .join(", "); - - Ok(format!( - r#"# cite-cli project manifest -# Generated by `cite-cli init`. Edit the values below to configure your project. - -# Project identity -# name: Display name of the project. -# language: Language code for generated content (e.g. "en"). -# metadata_file: Path to the content metadata file, relative to the project root. -# artist_id: Artist UUID from the database (provided by Aoux team). - -[project] -name = "{name}" -language = "{language}" -metadata_file = "{metadata_file}" -artist_id = "{artist_id}" - -# Build Configuration -# compiler_version: Compiler protocol version this project targets. -# incremental: Rebuild only when inputs change (true = incremental builds). -# output_format: Output format of the build artifact ("json"). - -[build] -compiler_version = {compiler_version} -incremental = {incremental} -output_format = "{output_format}" - -# Deployment (optional — Needed for staging podcasts to Aoux Server, uncomment to enable) -# staging_url: URL of the Aoux Server to deploy to. -# staging_service_key: Service key for the Aoux Server. -# subscription_plan: Subscription plan for your account (Basic/Premium/Pro) - -# [backend] -# staging_url = "" -# staging_service_key = "" -# subscription_plan = "" - -# Compiler, Assets, Validation -# enabled_extensions: Compiler extensions enabled for this project. -# audio_formats: Allowed audio asset extensions. -# image_formats: Allowed image asset extensions. -# strict: Enable strict validation rules (true = fail on warnings). - -[compiler] -enabled_extensions = [{enabled_extensions}] - -[assets] -audio_formats = [{audio_formats}] -image_formats = [{image_formats}] - -[validation] -strict = {strict} -"#, - name = manifest.project.name, - language = manifest.project.language, - metadata_file = manifest.project.metadata_file, - artist_id = manifest.project.artist_id, - compiler_version = manifest.build.compiler_version, - incremental = manifest.build.incremental, - output_format = manifest.build.output_format, - enabled_extensions = enabled_extensions, - audio_formats = audio_formats, - image_formats = image_formats, - strict = manifest.validation.strict, - )) -} - -fn metadata_template() -> String { - r#"# Podcast content metadata for cite-cli -# Each item under `podcasts` becomes one news/podcast entry when built. -# Uncomment the example below (remove the leading `# `) to define your first podcast. - -podcasts: - # - id: "" # Optional; auto-generated when empty - # title: "My First Podcast" # Display title of the episode - # file: content/episode.md # Markdown source for the episode content - # source_url: "" # Original URL (records the reference domain); optional - # category: "" # Category name; created in the DB if missing; optional - # thumbnail: "" # Path under assets/images; optional - # audio: "" # Path under assets/audio; optional - # citation: "" # BibTeX file; timelines are generated from its entries; optional -"# - .to_string() -} diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..d7228bd --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,728 @@ +use std::path::PathBuf; +use std::time::Duration; + +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, + widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}, +}; +use tokio::sync::mpsc; + +use crate::core::CiteError; +use crate::core::project::{self, ProjectContext}; +use crate::core::{compiler, deploy, doctor, scaffold}; + +struct TerminalGuard; +impl Drop for TerminalGuard { + fn drop(&mut self) { + ratatui::restore(); + } +} + +// Main entry point for the TUI + +pub async fn run_tui() -> Result<(), CiteError> { + let mut terminal = ratatui::init(); + let _guard = TerminalGuard; + terminal + .clear() + .map_err(|e| CiteError::Config(format!("{e}")))?; + + let cwd = std::env::current_dir().unwrap_or_default(); + let mut app = AppState::new(&cwd); + + loop { + terminal + .draw(|f| render(f, &app)) + .map_err(|e| CiteError::Config(format!("{e}")))?; + + tokio::select! { + biased; + // 1. Prioritize receiving task results + Some(lines) = app.rx.recv() => { + app.output.extend(lines); + app.output.push(String::new()); + app.busy = false; + app.refresh_projects(); + app.scroll = app.output.len().saturating_sub(1); // Auto-scroll to bottom + } + // 2. Handle UI events with a small sleep to yield to tokio runtime + _ = tokio::time::sleep(Duration::from_millis(50)) => { + if event::poll(Duration::from_millis(10)).map_err(|e| CiteError::Config(format!("{e}")))? { + if let Event::Key(key) = event::read().map_err(|e| CiteError::Config(format!("{e}")))? { + if key.kind == KeyEventKind::Press { + if key.code == KeyCode::Esc || (key.code == KeyCode::Char('q') && !matches!(app.focus, Focus::Args)) { + break; + } + app.handle_key(key); + } + } + } + } + } + } + + Ok(()) +} + +// Data types + +pub struct Cmd { + pub label: &'static str, + pub desc: &'static str, + pub args_hint: &'static str, + pub needs_project: bool, +} + +pub const CMDS: &[Cmd] = &[ + Cmd { + label: "Init", + desc: "Create a new project with starter files", + args_hint: "", + needs_project: false, + }, + Cmd { + label: "Build", + desc: "Execute the compiler protocol and build artifact", + args_hint: "[--force]", + needs_project: true, + }, + Cmd { + label: "Lint", + desc: "Run linting rules (naming, style, word counts)", + args_hint: "", + needs_project: true, + }, + Cmd { + label: "Status", + desc: "Show project health, validation, and sync state", + args_hint: "", + needs_project: true, + }, + Cmd { + label: "Doctor", + desc: "Diagnose common project issues and configuration", + args_hint: "", + needs_project: true, + }, + Cmd { + label: "Deploy", + desc: "Deploy the built project to Supabase staging", + args_hint: "[--dry-run]", + needs_project: true, + }, + Cmd { + label: "Rollback", + desc: "Roll back to the previous deployment", + args_hint: "", + needs_project: true, + }, + Cmd { + label: "Clean", + desc: "Remove build artifacts, cache, and temp files", + args_hint: "", + needs_project: true, + }, +]; + +#[derive(Clone, Copy, PartialEq)] +pub enum Focus { + Projects, + Commands, + Args, + Output, +} + +impl Focus { + fn label(self) -> &'static str { + match self { + Focus::Projects => "Projects", + Focus::Commands => "Commands", + Focus::Args => "Args", + Focus::Output => "Output", + } + } +} + +pub struct AppState { + cwd: PathBuf, + pub roots: Vec, + pub sel_project: usize, + pub focus: Focus, + pub sel_cmd: usize, + pub output: Vec, + pub scroll: usize, + pub busy: bool, + pub arg_input: String, + rx: mpsc::Receiver>, + tx: mpsc::Sender>, +} + +impl AppState { + pub fn new(cwd: &PathBuf) -> Self { + let (tx, rx) = mpsc::channel(32); + let roots = Self::discover(cwd); + + Self { + cwd: cwd.clone(), + roots, + sel_project: 0, + focus: Focus::Commands, + sel_cmd: 0, + output: vec![], + scroll: 0, + busy: false, + arg_input: String::new(), + rx, + tx, + } + } + + fn discover(cwd: &PathBuf) -> Vec { + let mut r = project::discover_projects(cwd); + r.sort(); + r + } + + fn refresh_projects(&mut self) { + self.cwd = std::env::current_dir().unwrap_or_else(|_| self.cwd.clone()); + self.roots = Self::discover(&self.cwd); + self.sel_project = self.sel_project.min(self.roots.len().saturating_sub(1)); + } + + pub fn project_names(&self) -> Vec { + if self.roots.is_empty() { + return vec![]; + } + self.roots + .iter() + .map(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("?") + .to_string() + }) + .collect() + } + + fn selected_root(&self) -> Option { + self.roots.get(self.sel_project).cloned() + } + + pub fn handle_key(&mut self, key: KeyEvent) { + if self.busy { + return; + } + + let has_args = !CMDS[self.sel_cmd].args_hint.is_empty(); + + match key.code { + KeyCode::Tab => { + self.focus = match self.focus { + Focus::Projects => Focus::Commands, + Focus::Commands => { + if has_args { + Focus::Args + } else { + Focus::Output + } + } + Focus::Args => Focus::Output, + Focus::Output => Focus::Projects, + }; + } + KeyCode::BackTab => { + self.focus = match self.focus { + Focus::Projects => Focus::Output, + Focus::Commands => Focus::Projects, + Focus::Args => Focus::Commands, + Focus::Output => { + if has_args { + Focus::Args + } else { + Focus::Commands + } + } + }; + } + KeyCode::Up => match self.focus { + Focus::Projects => self.sel_project = self.sel_project.saturating_sub(1), + Focus::Commands | Focus::Args => { + self.sel_cmd = self.sel_cmd.saturating_sub(1); + if CMDS[self.sel_cmd].args_hint.is_empty() { + self.focus = Focus::Commands; + } + } + Focus::Output => self.scroll = self.scroll.saturating_sub(1), + }, + KeyCode::Down => match self.focus { + Focus::Projects => { + self.sel_project = + (self.sel_project + 1).min(self.roots.len().saturating_sub(1)) + } + Focus::Commands | Focus::Args => { + self.sel_cmd = (self.sel_cmd + 1).min(CMDS.len().saturating_sub(1)); + if CMDS[self.sel_cmd].args_hint.is_empty() { + self.focus = Focus::Commands; + } + } + Focus::Output => self.scroll = self.scroll.saturating_add(1), + }, + KeyCode::Enter => match self.focus { + Focus::Projects => self.focus = Focus::Commands, + Focus::Commands | Focus::Args => self.start_cmd(), + _ => {} + }, + KeyCode::Backspace => { + if matches!(self.focus, Focus::Args) { + self.arg_input.pop(); + } + } + KeyCode::Char('r') if !matches!(self.focus, Focus::Args) => { + self.refresh_projects(); + self.output.clear(); + self.output.push("Refreshed".into()); + self.scroll = 0; + } + KeyCode::Char(ch) => { + if matches!(self.focus, Focus::Args) { + self.arg_input.push(ch); + } + } + KeyCode::PageUp => { + if matches!(self.focus, Focus::Output) { + self.scroll = self.scroll.saturating_sub(10); + } + } + KeyCode::PageDown => { + if matches!(self.focus, Focus::Output) { + self.scroll = self.scroll.saturating_add(10); + } + } + _ => {} + } + } + + fn start_cmd(&mut self) { + let root = self.selected_root(); + let cmd = &CMDS[self.sel_cmd]; + + if cmd.needs_project && root.is_none() { + self.output + .push("!! No projects found — select or init a project first".into()); + return; + } + + let raw_args = std::mem::take(&mut self.arg_input); + + let arg_display = if raw_args.is_empty() { + String::new() + } else { + format!(" ({raw_args})") + }; + + self.output.push(format!( + ">> {}{} {}", + cmd.label, + arg_display, + root.as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_default() + )); + + self.busy = true; + let tx = self.tx.clone(); + let idx = self.sel_cmd; + + tokio::spawn(async move { + let result = match idx { + 0 => exec_init(root, &raw_args).await, + 1 => exec_build(root, &raw_args).await, + 2 => exec_lint(root).await, + 3 => exec_status(root).await, + 4 => exec_doctor(root).await, + 5 => exec_deploy(root, &raw_args).await, + 6 => exec_rollback(root, &raw_args).await, + 7 => exec_clean(root).await, + _ => vec!["Unknown command".into()], + }; + let _ = tx.send(result).await; + }); + } +} + +// Rendering + +fn block(title: &str, focused: bool) -> Block<'_> { + let border_style = if focused { + Style::new().fg(Color::Cyan).bold() + } else { + Style::new().fg(Color::DarkGray) + }; + Block::default() + .borders(Borders::ALL) + .title(format!(" {title} ")) + .border_style(border_style) +} + +fn render(frame: &mut Frame, app: &AppState) { + let [header, body, status] = Layout::vertical([ + Constraint::Length(1), + Constraint::Fill(1), + Constraint::Length(1), + ]) + .areas(frame.area()); + + render_header(frame, header); + render_body(frame, body, app); + render_statusbar(frame, status, app); +} + +fn render_header(frame: &mut Frame, area: Rect) { + let t = Line::from(vec![Span::styled( + format!("v{}", env!("CARGO_PKG_VERSION")), + Style::new().fg(Color::DarkGray), + )]); + frame.render_widget(Paragraph::new(t), area); +} + +fn render_body(frame: &mut Frame, area: Rect, app: &AppState) { + let [left, right] = Layout::horizontal([Constraint::Max(25), Constraint::Fill(1)]).areas(area); + + let [top, bot] = Layout::vertical([Constraint::Max(12), Constraint::Fill(1)]).areas(right); + + let [cmd_list, cmd_doc] = + Layout::horizontal([Constraint::Max(25), Constraint::Fill(1)]).areas(top); + + render_projects(frame, left, app); + render_cmds(frame, cmd_list, app); + render_cmd_doc(frame, cmd_doc, app); + render_output(frame, bot, app); +} + +fn render_projects(frame: &mut Frame, area: Rect, app: &AppState) { + let is_focused = matches!(app.focus, Focus::Projects); + let items: Vec = app + .project_names() + .iter() + .enumerate() + .map(|(i, name)| { + let is_selected = i == app.sel_project; + let prefix = if is_selected { "▸ " } else { " " }; + let style = if is_selected && is_focused { + Style::new().fg(Color::Black).bg(Color::Cyan) + } else if is_selected { + Style::new().fg(Color::Cyan) + } else { + Style::new().fg(Color::Gray) + }; + ListItem::new(Line::from(Span::styled(format!("{prefix}{name}"), style))) + }) + .collect(); + + let title = format!("Projects ({})", app.roots.len()); + frame.render_widget(List::new(items).block(block(&title, is_focused)), area); +} + +fn render_cmds(frame: &mut Frame, area: Rect, app: &AppState) { + let is_focused = matches!(app.focus, Focus::Commands); + let items: Vec = CMDS + .iter() + .enumerate() + .map(|(i, cmd)| { + let is_selected = i == app.sel_cmd; + let prefix = if is_selected { "▸ " } else { " " }; + let style = if is_selected && is_focused { + Style::new().fg(Color::Black).bg(Color::Cyan) + } else if is_selected { + Style::new().fg(Color::Cyan) + } else { + Style::new().fg(Color::Gray) + }; + ListItem::new(Line::from(Span::styled( + format!("{prefix}{}", cmd.label), + style, + ))) + }) + .collect(); + + frame.render_widget(List::new(items).block(block("Commands", is_focused)), area); +} + +fn render_cmd_doc(frame: &mut Frame, area: Rect, app: &AppState) { + let cmd = &CMDS[app.sel_cmd]; + let is_focused = matches!(app.focus, Focus::Args); + let has_args = !cmd.args_hint.is_empty(); + + let mut lines = vec![ + Line::from(Span::styled(cmd.label, Style::new().bold().fg(Color::Cyan))), + Line::from(""), + Line::from(cmd.desc), + ]; + + if has_args { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Arguments:", + Style::new().bold().fg(Color::Gray), + ))); + lines.push(Line::from(Span::raw(format!(" {}", cmd.args_hint)))); + + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled("Input: ", Style::new().bold().fg(Color::Gray)), + Span::styled( + if app.arg_input.is_empty() { + "Type arguments here..." + } else { + &app.arg_input + }, + if is_focused { + Style::new() + .fg(Color::Cyan) + .add_modifier(Modifier::UNDERLINED) + } else { + Style::new().fg(Color::Gray) + }, + ), + ])); + } + + frame.render_widget( + Paragraph::new(Text::from(lines)).block(block("Details", is_focused)), + area, + ); +} + +fn render_output(frame: &mut Frame, area: Rect, app: &AppState) { + let lines: Vec = app.output.iter().map(|l| Line::raw(l.clone())).collect(); + + let visible_lines = area.height.saturating_sub(3) as usize; + let max_scroll = app.output.len().saturating_sub(visible_lines); + let scroll_y = app.scroll.min(max_scroll) as u16; + + let para = Paragraph::new(Text::from(lines)) + .block(block("Output", matches!(app.focus, Focus::Output))) + .scroll((scroll_y, 0)) + .wrap(Wrap { trim: false }); + + frame.render_widget(para, area); +} + +fn render_statusbar(frame: &mut Frame, area: Rect, app: &AppState) { + let mut left_spans = vec![]; + + if app.busy { + left_spans.push(Span::styled( + " RUNNING ", + Style::new().fg(Color::Black).bg(Color::Yellow), + )); + } else { + left_spans.push(Span::styled( + " READY ", + Style::new().fg(Color::Black).bg(Color::Green), + )); + } + + left_spans.push(Span::raw(" ")); + left_spans.push(Span::styled( + app.focus.label(), + Style::new().bold().fg(Color::Cyan), + )); + + if !app.roots.is_empty() { + left_spans.push(Span::raw(" ")); + let proj_name = app + .project_names() + .get(app.sel_project) + .cloned() + .unwrap_or_default(); + left_spans.push(Span::styled(proj_name, Style::new().fg(Color::Gray))); + } + + if app.busy { + let cmd = &CMDS[app.sel_cmd]; + left_spans.push(Span::raw(" ")); + left_spans.push(Span::styled( + format!("Executing: {}", cmd.label), + Style::new().fg(Color::Yellow), + )); + } + + let right_text = " [Tab/Shift+Tab]:Cycle [↑/↓]:Nav [Enter]:Exec [r]:Refresh [PgUp/Dn]:Scroll [Esc/q]:Quit "; + + let [left_area, right_area] = Layout::horizontal([ + Constraint::Fill(1), + Constraint::Length(right_text.len() as u16), + ]) + .areas(area); + + frame.render_widget( + Paragraph::new(Line::from(left_spans)) + .style(Style::new().bg(Color::DarkGray).fg(Color::White)), + left_area, + ); + + frame.render_widget( + Paragraph::new(Line::from(right_text)) + .style(Style::new().bg(Color::DarkGray).fg(Color::Gray)) + .alignment(Alignment::Right), + right_area, + ); +} + +// Command executors + +async fn exec_init(root: Option, raw: &str) -> Vec { + let parent = root + .as_deref() + .and_then(|r| r.parent()) + .unwrap_or_else(|| std::path::Path::new(".")); + + let name = raw.split_whitespace().next().unwrap_or("new-project"); + let target = parent.join(name); + + match scaffold::init_project(name, &target) { + Ok(report) => { + let mut l = report.to_lines(); + l.push(format!("Project '{name}' created at {}", target.display())); + l + } + Err(e) => vec![format!("Init failed: {e}")], + } +} + +async fn exec_build(root: Option, raw: &str) -> Vec { + let Some(root) = root else { + return vec!["No project selected".into()]; + }; + + let force = raw.split_whitespace().any(|w| w == "--force"); + + match ProjectContext::load(&root) { + Ok(ctx) => match compiler::compile(&ctx, force).await { + Ok(r) => r.to_lines(), + Err(e) => vec![format!("Build failed: {e}")], + }, + Err(e) => vec![format!("{e}")], + } +} + +async fn exec_lint(root: Option) -> Vec { + let Some(root) = root else { + return vec!["No project selected".into()]; + }; + + match ProjectContext::load(&root) { + Ok(ctx) => { + let mut lines = doctor::lint_all(&ctx).to_lines(); + if lines.is_empty() { + lines.push("Lint complete — no issues found".into()); + } + lines + } + Err(e) => vec![format!("{e}")], + } +} + +async fn exec_status(root: Option) -> Vec { + let Some(root) = root else { + return vec!["No project selected".into()]; + }; + + match ProjectContext::load(&root) { + Ok(ctx) => { + let mut lines = Vec::new(); + lines.push(format!("Name: {}", ctx.manifest.project.name)); + lines.push(format!("Root: {}", ctx.root.display())); + lines.push(format!("Artist ID: {}", ctx.manifest.project.artist_id)); + if let Some(b) = &ctx.manifest.backend { + if let Some(u) = &b.staging_url { + lines.push(format!("Staging: {u}")); + } + } + lines.push(format!("Podcasts: {}", ctx.metadata.podcasts.len())); + + let build_path = ctx.build_dir().join("content.json"); + lines.push(if build_path.exists() { + "Build: exists".into() + } else { + "Build: not built".into() + }); + lines + } + Err(e) => vec![format!("{e}")], + } +} + +async fn exec_doctor(root: Option) -> Vec { + let Some(root) = root else { + return vec!["No project selected".into()]; + }; + + match ProjectContext::load(&root) { + Ok(ctx) => match doctor::run(&ctx) { + Ok(o) => { + let mut lines = o.to_lines(); + if lines.is_empty() { + lines.push("Doctor check complete — no issues found".into()); + } + lines + } + Err(e) => vec![format!("Doctor failed: {e}")], + }, + Err(e) => vec![format!("{e}")], + } +} + +async fn exec_deploy(root: Option, raw: &str) -> Vec { + let Some(root) = root else { + return vec!["No project selected".into()]; + }; + + let dry_run = raw.split_whitespace().any(|w| w == "--dry-run"); + + match ProjectContext::load(&root) { + Ok(ctx) => match deploy::deploy(&ctx, dry_run).await { + Ok(msg) => vec![msg], + Err(e) => vec![format!("Deploy failed: {e}")], + }, + Err(e) => vec![format!("{e}")], + } +} + +async fn exec_rollback(root: Option, raw: &str) -> Vec { + let Some(root) = root else { + return vec!["No project selected".into()]; + }; + + let id = raw.split_whitespace().next().unwrap_or(""); + if id.is_empty() { + return vec!["No deployment ID provided".into()]; + } + + match ProjectContext::load(&root) { + Ok(ctx) => match deploy::rollback(&ctx, id).await { + Ok(msg) => vec![msg], + Err(e) => vec![format!("Rollback failed: {e}")], + }, + Err(e) => vec![format!("{e}")], + } +} + +async fn exec_clean(root: Option) -> Vec { + let Some(root) = root else { + return vec!["No project selected".into()]; + }; + + match ProjectContext::load(&root) { + Ok(ctx) => match ctx.clean() { + Ok(()) => vec!["Cleaned build artifacts".into()], + Err(e) => vec![format!("Clean failed: {e}")], + }, + Err(e) => vec![format!("{e}")], + } +} diff --git a/src/validation.rs b/src/validation.rs deleted file mode 100644 index c2867e3..0000000 --- a/src/validation.rs +++ /dev/null @@ -1,219 +0,0 @@ -use crate::error::ValidationReport; -use crate::project::ProjectContext; -use std::path::Path; -use tracing::instrument; - -#[instrument(skip(ctx), fields(project = %ctx.manifest.project.name))] -pub fn validate_all(ctx: &ProjectContext) -> ValidationReport { - let mut report = ValidationReport::new(); - - validate_project_structure(ctx, &mut report); - validate_file_existence(ctx, &mut report); - validate_asset_formats(ctx, &mut report); - - report -} - -fn validate_project_structure(ctx: &ProjectContext, report: &mut ValidationReport) { - let required = [ - ("cite.toml", ctx.root.join("cite.toml")), - ( - &ctx.manifest.project.metadata_file, - ctx.root.join(&ctx.manifest.project.metadata_file), - ), - ]; - for (name, path) in &required { - if !path.exists() { - report.error(format!( - "Required file '{name}' not found at {}", - path.display() - )); - } - } - - let dirs = [ - ("content", ctx.content_dir()), - ("assets/images", ctx.root.join("assets/images")), - ("assets/audio", ctx.root.join("assets/audio")), - ]; - for (name, path) in &dirs { - if !path.is_dir() { - report.warning(format!( - "Directory '{name}' does not exist at {}", - path.display() - )); - } - } -} - -fn validate_file_existence(ctx: &ProjectContext, report: &mut ValidationReport) { - for pod in &ctx.metadata.podcasts { - let path = ctx.root.join(&pod.file); - if !path.exists() { - report.error(format!( - "Podcast '{}' references file '{}' which does not exist", - pod.title, pod.file - )); - } - - if let Some(cit) = &pod.citation { - let cit_path = ctx.root.join(cit); - if !cit_path.exists() { - report.warning(format!( - "Podcast '{}' references citation file '{}' which does not exist", - pod.title, cit - )); - } - } - - if let Some(audio) = &pod.audio { - let audio_path = ctx.root.join(audio); - if !audio_path.exists() { - report.error(format!( - "Podcast '{}' references audio file '{}' which does not exist", - pod.title, audio - )); - } - } - } -} - -fn validate_asset_formats(ctx: &ProjectContext, report: &mut ValidationReport) { - let allowed_audio: std::collections::HashSet<&str> = ctx - .manifest - .assets - .audio_formats - .iter() - .map(|s| s.as_str()) - .collect(); - - for pod in &ctx.metadata.podcasts { - if let Some(audio) = &pod.audio { - let ext = Path::new(audio) - .extension() - .and_then(|e| e.to_str()) - .unwrap_or(""); - if !allowed_audio.contains(ext) { - report.warning(format!( - "Podcast '{}' has audio file '{audio}' with extension '{ext}' not in allowed audio formats {:?}", - pod.title, ctx.manifest.assets.audio_formats - )); - } - } - - let ext = Path::new(&pod.file) - .extension() - .and_then(|e| e.to_str()) - .unwrap_or(""); - if !matches!(ext, "md" | "rst") { - report.warning(format!( - "Podcast '{}' has file '{}' with unexpected extension '{ext}'", - pod.title, pod.file - )); - } - } -} - -#[instrument(skip(ctx), fields(project = %ctx.manifest.project.name))] -pub fn lint_all(ctx: &ProjectContext) -> ValidationReport { - let mut report = ValidationReport::new(); - - for pod in &ctx.metadata.podcasts { - let path = ctx.root.join(&pod.file); - if path.exists() - && let Ok(content) = std::fs::read_to_string(&path) - { - let word_count = content.split_whitespace().count(); - if word_count < 10 { - report.warning(format!( - "Podcast '{}' is very short ({} words)", - pod.title, word_count - )); - } - } - } - - report -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::manifest::*; - use crate::metadata::*; - use crate::project::ProjectContext; - - fn test_context(podcasts: Vec) -> (ProjectContext, tempfile::TempDir) { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().to_path_buf(); - let manifest = Manifest::default_template("test"); - std::fs::write( - root.join("cite.toml"), - toml::to_string_pretty(&manifest).unwrap(), - ) - .unwrap(); - std::fs::write(root.join("metadata.yml"), "podcasts: []\n").unwrap(); - std::fs::create_dir_all(root.join("content")).unwrap(); - std::fs::create_dir_all(root.join("assets/audio")).unwrap(); - std::fs::create_dir_all(root.join("assets/images")).unwrap(); - let ctx = ProjectContext { - root, - manifest, - metadata: Metadata { podcasts }, - }; - (ctx, dir) - } - - #[test] - fn test_validate_empty_metadata() { - let (ctx, _dir) = test_context(vec![]); - let report = validate_all(&ctx); - assert!(!report.has_errors()); - } - - #[test] - fn test_validate_file_not_found() { - let (ctx, _dir) = test_context(vec![Podcast { - id: "abc".into(), - title: "Missing".into(), - file: "content/nonexistent.md".into(), - source_url: None, - category: None, - thumbnail: None, - audio: None, - citation: None, - content: None, - }]); - let report = validate_all(&ctx); - assert!(report.has_errors()); - assert!(report.errors.iter().any(|e| e.contains("does not exist"))); - } - - #[test] - fn test_lint_short_content() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().to_path_buf(); - std::fs::create_dir_all(root.join("content")).unwrap(); - std::fs::write(root.join("content/short.md"), "Hi").unwrap(); - let ctx = ProjectContext { - root, - manifest: Manifest::default_template("test"), - metadata: Metadata { - podcasts: vec![Podcast { - id: "abc".into(), - title: "Short".into(), - file: "content/short.md".into(), - source_url: None, - category: None, - thumbnail: None, - audio: None, - citation: None, - content: None, - }], - }, - }; - let report = lint_all(&ctx); - assert!(report.has_warnings()); - assert!(report.warnings.iter().any(|w| w.contains("very short"))); - } -} diff --git a/tests/integration.rs b/tests/integration.rs index 9032803..8a8fb07 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -87,7 +87,7 @@ fn init_creates_project_structure() { assert!(h.project.join("metadata.yml").exists(), "metadata.yml"); assert!(h.project.join("content").is_dir(), "content/"); assert!(h.project.join("assets/audio").is_dir(), "assets/audio/"); - assert!(h.project.join("assets/images").is_dir(), "assets/images/"); + assert!(h.project.join("assets/image").is_dir(), "assets/image/"); assert!(!h.project.join("build").exists()); assert!(h.project.join(".gitignore").exists(), ".gitignore"); } @@ -99,19 +99,19 @@ fn init_is_idempotent_on_existing_project() { ProjectHarness::output(&["init", "--path", h.project.to_str().unwrap(), "existing"]); assert!(ok); assert!(stderr.contains("ready")); - assert!(stderr.contains("skipped")); + assert!(stderr.contains("Skipped")); } -// ── validate ──────────────────────────────────────────────────── +// ── doctor ──────────────────────────────────────────────────── #[test] -fn validate_passes_on_empty_project() { +fn doctor_passes_on_empty_project() { let h = ProjectHarness::new("empty"); - h.run_ok(&["validate"]); + h.run_ok(&["doctor"]); } #[test] -fn validate_catches_missing_file() { +fn doctor_catches_missing_file() { let h = ProjectHarness::new("missing-file"); h.write_metadata( r#" @@ -120,16 +120,16 @@ podcasts: file: content/nonexistent.md "#, ); - let (_, stderr, ok) = h.run(&["validate"]); + let (_, stderr, ok) = h.run(&["doctor"]); assert!(!ok); assert!(stderr.contains("does not exist")); } #[test] -fn validate_catches_missing_metadata() { +fn doctor_catches_missing_metadata() { let h = ProjectHarness::new("no-meta"); fs::remove_file(h.project.join("metadata.yml")).unwrap(); - let (_, stderr, ok) = h.run(&["validate"]); + let (_, stderr, ok) = h.run(&["doctor"]); assert!(!ok); assert!(stderr.contains("not found")); } @@ -298,6 +298,38 @@ podcasts: ); } +#[test] +fn build_empty_project_succeeds() { + let h = ProjectHarness::new("empty-build"); + h.run_ok(&["build"]); + let bundle = h.read_bundle(); + let pods = bundle["podcasts"].as_array().unwrap(); + assert_eq!(pods.len(), 0, "template has no default podcast"); +} + +#[test] +fn build_force_rebuilds() { + let h = ProjectHarness::new("force-build"); + h.write_content("content/a.md", "v1"); + h.write_metadata( + r#" +podcasts: + - title: "A" + file: content/a.md +"#, + ); + h.run_ok(&["build"]); + let first = h.read_bundle(); + + h.run_ok(&["build", "--force"]); + let second = h.read_bundle(); + assert_eq!( + first["podcasts"][0]["content"], + second["podcasts"][0]["content"] + ); + assert_eq!(second["compiler_version"], 1.0); +} + // ── status ────────────────────────────────────────────────────── #[test] @@ -385,7 +417,6 @@ fn deploy_fails_without_build() { ).unwrap(); let (_, stderr, ok) = h.run(&["deploy"]); assert!(!ok); - eprintln!("DEBUG stderr: {}", stderr); assert!(stderr.contains("No build artifact")); } @@ -419,7 +450,7 @@ podcasts: "#, ); - h.run_ok(&["validate"]); + h.run_ok(&["doctor"]); h.run_ok(&["lint"]); h.run_ok(&["build"]); @@ -460,5 +491,5 @@ fn help_prints_usage() { #[test] fn verbose_flag_works() { let h = ProjectHarness::new("verbose-test"); - h.run_ok(&["validate", "--verbose"]); + h.run_ok(&["doctor", "--verbose"]); }