use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::Parser;
use serde_json::from_reader;
use crate::manifest::collect_emissions;
use crate::manifest::{find_emissions_dir, generate_manifest, validate_manifest};
use crate::ui;
use portaki_sdk::manifest::ModuleManifest;
#[derive(Debug, Parser)]
pub struct LintArgs {
#[arg(long)]
pub manifest: Option<PathBuf>,
#[arg(skip)]
pub nested: bool,
}
pub fn run(args: LintArgs) -> Result<()> {
if !args.nested {
ui::header(
"portaki lint",
"Check that everything the manifest names actually resolves.",
);
}
let module_root = std::env::current_dir().context("current_dir")?;
let manifest_path = args
.manifest
.unwrap_or_else(|| module_root.join("target/portaki/manifest.json"));
let reading = ui::step("reading the manifest");
let manifest = if manifest_path.exists() {
let file = std::fs::File::open(&manifest_path)?;
let manifest = from_reader::<_, ModuleManifest>(file)?;
reading.done(format!("read {}", manifest_path.display()));
manifest
} else if let Some(emissions_dir) = find_emissions_dir(&module_root) {
let emissions = collect_emissions(&emissions_dir)?;
let manifest = generate_manifest(
&emissions,
"fr-FR",
&["fr-FR".to_string(), "en-US".to_string()],
)?;
reading.done("read the SDK emissions (no build output yet)");
manifest
} else {
reading.abandon();
anyhow::bail!("no manifest or emissions found — run portaki build first");
};
let checking = ui::step("checking capability ids, connector bindings, and i18n keys");
validate_manifest(&manifest, &module_root.join("i18n")).map_err(|failure| {
checking.abandon();
failure
})?;
assert_connector_permissions(&module_root, &manifest).map_err(|failure| {
checking.abandon();
failure
})?;
assert_versions_agree(&module_root, &manifest).map_err(|failure| {
checking.abandon();
failure
})?;
checking.done(format!("{} passes", manifest.id));
ui::detail("capability ids, connector bindings and i18n keys all resolve");
ui::blank();
Ok(())
}
fn assert_connector_permissions(
module_root: &std::path::Path,
manifest: &ModuleManifest,
) -> Result<()> {
let granted = crate::commands::connectors::granted_connector_permissions(module_root);
let missing = crate::commands::connectors::missing_permissions(&manifest.connectors, &granted);
if missing.is_empty() {
return Ok(());
}
anyhow::bail!(
"connector {} is declared but portaki.module.json does not permit it — add \
\"connectors:{}\" to permissions",
missing.join(", "),
missing.first().cloned().unwrap_or_default()
)
}
fn assert_versions_agree(module_root: &std::path::Path, manifest: &ModuleManifest) -> Result<()> {
let cargo = module_root.join("Cargo.toml");
let Ok(text) = std::fs::read_to_string(&cargo) else {
return Ok(());
};
let Some(declared) = crate_version(&text) else {
return Ok(());
};
if declared == manifest.version {
return Ok(());
}
anyhow::bail!(
"Cargo.toml says {declared} and the manifest says {} — a release bumps both, so one of \
them was missed",
manifest.version
)
}
fn crate_version(cargo: &str) -> Option<String> {
let mut in_package = false;
for line in cargo.lines() {
let line = line.trim();
if line.starts_with('[') {
in_package = line == "[package]";
continue;
}
if !in_package {
continue;
}
if let Some(rest) = line.strip_prefix("version") {
let rest = rest.trim_start().strip_prefix('=')?.trim();
return rest
.strip_prefix('"')
.and_then(|rest| rest.strip_suffix('"'))
.map(str::to_string);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_crate_version_is_read_from_its_own_section() {
let cargo = r#"
[package]
name = "weather"
version = "0.3.24"
[dependencies]
serde = { version = "1", features = ["derive"] }
"#;
assert_eq!(crate_version(cargo).as_deref(), Some("0.3.24"));
}
#[test]
fn a_dependency_version_is_not_mistaken_for_the_crate() {
let cargo = "[dependencies]\nserde = { version = \"1\" }\n";
assert!(crate_version(cargo).is_none());
}
#[test]
fn a_workspace_inherited_version_is_left_alone() {
let cargo = "[package]\nname = \"weather\"\nversion.workspace = true\n";
assert!(crate_version(cargo).is_none());
}
}