//! `pic init [slug] [--dir .]` — scaffold a new declarative project: a //! `picloud.toml` describing a minimal working app (one `hello` endpoint + //! route), its `scripts/hello.rhai` source, and a `.gitignore` that ignores //! the project tool's `.picloud/` link-state directory. //! //! Offline by design — it never contacts the server. Run `pic plan` to //! preview the create, then `pic apply` to deploy. Refuses to overwrite an //! existing `picloud.toml` unless `--force`. use std::fs; use std::path::Path; use anyhow::{bail, Context, Result}; use picloud_shared::{DispatchMode, HostKind, PathKind, ScriptKind}; use crate::manifest::{Manifest, ManifestApp, ManifestRoute, ManifestScript, MANIFEST_FILE}; use crate::output::{KvBlock, OutputMode}; const HEADER: &str = "\ # picloud project manifest — the declarative desired state for one app. # Edit, then `pic plan` to preview changes and `pic apply` to reconcile. # Scripts live under scripts/ and are referenced by `file`. \n"; const EXAMPLES: &str = "\ \n# --------------------------------------------------------------------------- # More to add (uncomment and adapt): # # [[scripts]] # name = \"lib\" # file = \"scripts/lib.rhai\" # kind = \"module\" # default: endpoint # # [[routes]] # script = \"hello\" # method = \"POST\" # omit for ANY # host_kind = \"any\" # path_kind = \"param\" # exact | prefix | param # path = \"/hello/:name\" # # [[triggers.cron]] # script = \"hello\" # schedule = \"0 0 * * * *\" # 6-field cron (seconds first) # timezone = \"UTC\" # # [secrets] # names only — push values with `pic secret set` # names = [\"STRIPE_KEY\"] "; const HELLO_RHAI: &str = "\ // A minimal endpoint script. Its return value is the HTTP response body. // `ctx` exposes the request; see the stdlib reference for the full SDK. \"Hello from PiCloud!\" "; const GITIGNORE_LINE: &str = ".picloud/"; pub fn run( dir: &Path, slug_arg: Option<&str>, name_arg: Option<&str>, force: bool, mode: OutputMode, ) -> Result<()> { let slug = resolve_slug(dir, slug_arg)?; let name = name_arg.map_or_else(|| title_from_slug(&slug), str::to_string); let manifest_path = dir.join(MANIFEST_FILE); if manifest_path.exists() && !force { bail!( "{} already exists; refusing to overwrite (use --force)", manifest_path.display() ); } let manifest = scaffold_manifest(&slug, &name); // Build the file as a commented header + the (valid, round-trippable) // active manifest + commented examples. Rendering the active part through // `to_toml` guarantees it parses and matches the wire model. let body = format!("{HEADER}{}{EXAMPLES}", manifest.to_toml()?); fs::create_dir_all(dir.join("scripts")).context("creating scripts/ directory")?; let hello_path = dir.join("scripts/hello.rhai"); let wrote_hello = !hello_path.exists(); if wrote_hello { fs::write(&hello_path, HELLO_RHAI).context("writing scripts/hello.rhai")?; } fs::write(&manifest_path, body).with_context(|| format!("writing {MANIFEST_FILE}"))?; ensure_gitignored(dir)?; let mut block = KvBlock::new(); block .field("manifest", manifest_path.display().to_string()) .field("app", slug) .field( "scripts", if wrote_hello { "scripts/hello.rhai" } else { "scripts/hello.rhai (kept existing)" } .to_string(), ) .field("next", "pic plan then pic apply".to_string()); block.print(mode); Ok(()) } /// The minimal working project: one `hello` endpoint bound to `GET /hello`. fn scaffold_manifest(slug: &str, name: &str) -> Manifest { Manifest { app: ManifestApp { slug: slug.to_string(), name: name.to_string(), description: None, }, scripts: vec![ManifestScript { name: "hello".into(), file: "scripts/hello.rhai".into(), kind: ScriptKind::Endpoint, description: None, timeout_seconds: None, memory_limit_mb: None, sandbox: None, enabled: true, }], routes: vec![ManifestRoute { script: "hello".into(), method: Some("GET".into()), host_kind: HostKind::Any, host: String::new(), host_param_name: None, path_kind: PathKind::Exact, path: "/hello".into(), dispatch_mode: DispatchMode::Sync, enabled: true, }], triggers: crate::manifest::ManifestTriggers::default(), secrets: crate::manifest::ManifestSecrets::default(), } } /// Use the explicit slug if given, else derive one from the target /// directory's name. Either way it must satisfy the app-slug rule. fn resolve_slug(dir: &Path, slug_arg: Option<&str>) -> Result { if let Some(s) = slug_arg { if !is_valid_slug(s) { bail!("invalid app slug `{s}`: use lowercase letters, digits, and dashes (start alphanumeric, max 63)"); } return Ok(s.to_string()); } // Derive from the directory's own name. Use the path as given first — // `canonicalize` requires the dir to already exist, which breaks the // natural `pic init --dir new-project` flow. Fall back to canonicalizing // only when the path has no final component of its own (e.g. `.`). let base = dir .file_name() .map(|n| n.to_string_lossy().into_owned()) .or_else(|| { dir.canonicalize() .ok() .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned())) }) .unwrap_or_default(); let derived = slugify(&base); if !is_valid_slug(&derived) { bail!( "could not derive a valid app slug from directory `{base}`; \ pass one explicitly, e.g. `pic init my-app`" ); } Ok(derived) } /// Lowercase, map runs of non-`[a-z0-9]` to a single `-`, trim dashes. fn slugify(s: &str) -> String { let mut out = String::with_capacity(s.len()); let mut prev_dash = false; for c in s.chars() { if c.is_ascii_alphanumeric() { out.push(c.to_ascii_lowercase()); prev_dash = false; } else if !prev_dash { out.push('-'); prev_dash = true; } } out.trim_matches('-').to_string() } /// The canonical app-slug rule (mirrors the server): `^[a-z0-9][a-z0-9-]{0,62}$`. fn is_valid_slug(s: &str) -> bool { if s.is_empty() || s.len() > 63 { return false; } let mut chars = s.chars(); let first = chars.next().expect("non-empty checked above"); if !(first.is_ascii_lowercase() || first.is_ascii_digit()) { return false; } chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') } /// Title-case a slug for a default display name: `my-blog` → `My Blog`. fn title_from_slug(slug: &str) -> String { slug.split('-') .filter(|w| !w.is_empty()) .map(|w| { let mut c = w.chars(); c.next().map_or_else(String::new, |f| { f.to_ascii_uppercase().to_string() + c.as_str() }) }) .collect::>() .join(" ") } /// Ensure `.gitignore` ignores `.picloud/` (the project tool's link state). /// Appends the line if missing; creates the file if absent. A repo without /// git still gets a correct `.gitignore` for when it's initialized. fn ensure_gitignored(dir: &Path) -> Result<()> { let path = dir.join(".gitignore"); // Only a genuinely-absent file is treated as empty; an existing-but- // unreadable `.gitignore` must error rather than be silently clobbered. let existing = match fs::read_to_string(&path) { Ok(s) => s, Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(), Err(e) => return Err(e).context("reading .gitignore"), }; if existing.lines().any(|l| l.trim() == GITIGNORE_LINE) { return Ok(()); } let mut next = existing; if !next.is_empty() && !next.ends_with('\n') { next.push('\n'); } next.push_str(GITIGNORE_LINE); next.push('\n'); fs::write(&path, next).context("updating .gitignore")?; Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn scaffold_is_valid_and_round_trips() { let m = scaffold_manifest("blog", "Blog"); let body = format!("{HEADER}{}{EXAMPLES}", m.to_toml().unwrap()); // The active manifest (header/examples are comments) must parse back // to exactly the scaffold — the commented examples are inert. let parsed = Manifest::parse(&body).expect("scaffold must be valid TOML"); assert_eq!(parsed, m); // And it's a deployable project: one endpoint + its route. assert_eq!(parsed.scripts.len(), 1); assert_eq!(parsed.routes.len(), 1); assert_eq!(parsed.routes[0].script, "hello"); } #[test] fn slugify_and_validation() { assert_eq!(slugify("My Blog!"), "my-blog"); assert_eq!(slugify(" weird__name "), "weird-name"); assert_eq!(slugify("Project (2026)"), "project-2026"); assert!(is_valid_slug("blog")); assert!(is_valid_slug("a1-b2")); assert!(!is_valid_slug("")); assert!(!is_valid_slug("-leading")); assert!(!is_valid_slug(&"a".repeat(64))); } #[test] fn title_from_slug_humanizes() { assert_eq!(title_from_slug("my-blog"), "My Blog"); assert_eq!(title_from_slug("api"), "Api"); } }