pub(crate) mod list;
pub(crate) mod templates;
use clap::{Args as ClapArgs, Subcommand};
use influxdb3_plugin_schemas::{ArtifactsUrl, PluginName, SchemaError, TriggerType};
use influxdb3_plugin_sdk::scaffold;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use crate::cli_error::CliError;
use crate::color::Stream;
use crate::output::error_mapping::{ErrorContext, json_error_from_sdk};
use crate::output::json::{JsonError, NewOutput, write_envelope_ok};
use crate::output::{Env, OutputMode, RealEnv, resolve_output_mode};
use crate::path_display::{absolutize_for_json, display_relative_to_cwd};
use crate::style::Palette;
use templates::TemplateMetadata;
#[derive(Debug, ClapArgs)]
pub(crate) struct GlobalFlags {
#[arg(long, value_enum)]
pub output: Option<OutputMode>,
#[arg(long)]
pub force: bool,
}
#[derive(Debug, Subcommand)]
#[command(
rename_all = "snake_case",
override_usage = "\
influxdb3-plugin new <TEMPLATE> [PATH] [OPTIONS]
influxdb3-plugin new list [OPTIONS]",
after_help = "\
Run `influxdb3-plugin new list` to see available templates, \
or `influxdb3-plugin new <template> --help` for per-template options. \
Pass `--output` after the template name (e.g. `new index --output json`)."
)]
pub(crate) enum NewCommand {
List(list::Args),
#[command(hide = true)]
ProcessWrites(templates::process_writes::Args),
#[command(hide = true)]
ProcessScheduledCall(templates::process_scheduled_call::Args),
#[command(hide = true)]
ProcessRequest(templates::process_request::Args),
#[command(hide = true)]
Index(templates::index::Args),
}
impl NewCommand {
pub(crate) fn run(self) -> anyhow::Result<()> {
match self {
Self::List(a) => list::run(a),
Self::ProcessWrites(a) => templates::process_writes::run(a),
Self::ProcessScheduledCall(a) => templates::process_scheduled_call::run(a),
Self::ProcessRequest(a) => templates::process_request::run(a),
Self::Index(a) => templates::index::run(a),
}
}
}
pub(crate) fn plugin_scaffold(
metadata: &'static TemplateMetadata,
trigger: TriggerType,
global: GlobalFlags,
path: PathBuf,
name_arg: Option<String>,
database_version: Option<String>,
) -> anyhow::Result<()> {
run_plugin_with_env(
metadata,
trigger,
global,
path,
name_arg,
database_version,
&RealEnv,
)
}
pub(crate) fn index_scaffold(
metadata: &'static TemplateMetadata,
global: GlobalFlags,
path: PathBuf,
artifacts_url: Option<String>,
) -> anyhow::Result<()> {
run_index_with_env(metadata, global, path, artifacts_url, &RealEnv)
}
fn run_plugin_with_env(
metadata: &'static TemplateMetadata,
trigger: TriggerType,
global: GlobalFlags,
path: PathBuf,
name_arg: Option<String>,
database_version: Option<String>,
env: &dyn Env,
) -> anyhow::Result<()> {
let mode = resolve_output_mode(global.output, env);
let stdout_palette = Palette::for_stream(Stream::Stdout, mode, env, env.stdout_is_terminal());
let name = resolve_plugin_name(&path, name_arg)?;
if let Some(raw) = database_version.as_deref()
&& let Err(e) = semver::VersionReq::parse(raw)
{
return Err(CliError::usage(JsonError {
code: "usage::invalid_database_version".into(),
message: format!("invalid --database-version {raw:?}: {e}"),
field: None,
details: Some(serde_json::json!({
"value": raw,
"reason": e.to_string(),
})),
diagnostics: vec![],
cause: vec![],
}));
}
let parent = path.parent().unwrap_or_else(|| Path::new("."));
check_sibling_canonical_collision(parent, &name)?;
let target_dir = absolutize_for_json(&path)?;
scaffold::plugin(
&target_dir,
&name,
trigger,
database_version.as_deref(),
global.force,
)
.map_err(|e| CliError::runtime(json_error_from_sdk(&e, ErrorContext::NewPlugin)))?;
let summary = Summary {
kind: SummaryKind::Plugin,
template: metadata,
target_dir,
name: Some(name),
files_written: vec![
PathBuf::from("manifest.toml"),
PathBuf::from("__init__.py"),
PathBuf::from("README.md"),
],
};
render(&summary, mode, stdout_palette)
}
fn run_index_with_env(
metadata: &'static TemplateMetadata,
global: GlobalFlags,
path: PathBuf,
artifacts_url: Option<String>,
env: &dyn Env,
) -> anyhow::Result<()> {
let mode = resolve_output_mode(global.output, env);
let stdout_palette = Palette::for_stream(Stream::Stdout, mode, env, env.stdout_is_terminal());
if let Some(raw) = artifacts_url.as_deref()
&& let Err(e) = ArtifactsUrl::try_new(raw)
{
return Err(CliError::usage(JsonError {
code: "usage::invalid_artifacts_url".into(),
message: format!("invalid --artifacts-url {raw:?}: {e}"),
field: None,
details: Some(serde_json::json!({
"value": raw,
"reason": e.to_string(),
})),
diagnostics: vec![],
cause: vec![],
}));
}
let target_dir = absolutize_for_json(&path)?;
scaffold::index(&target_dir, artifacts_url.as_deref(), global.force)
.map_err(|e| CliError::runtime(json_error_from_sdk(&e, ErrorContext::NewIndex)))?;
let summary = Summary {
kind: SummaryKind::Index,
template: metadata,
target_dir,
name: None,
files_written: vec![PathBuf::from("index.json")],
};
render(&summary, mode, stdout_palette)
}
fn resolve_plugin_name(dir: &Path, name_arg: Option<String>) -> anyhow::Result<String> {
let (candidate, source_was_explicit) = match name_arg {
Some(n) => (n, true),
None => {
let absolute = std::path::absolute(dir).map_err(|_source| {
CliError::runtime(JsonError {
code: "new::path_resolution_failed".into(),
message: format!("could not resolve path {dir:?}: {_source}"),
field: None,
details: None,
diagnostics: vec![],
cause: vec![],
})
})?;
let basename = absolute
.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| {
CliError::runtime(JsonError {
code: "new::derived_name_unavailable".into(),
message: format!(
"could not derive a plugin name from path {dir:?}; \
pass --name <name> explicitly"
),
field: None,
details: None,
diagnostics: vec![],
cause: vec![],
})
})?
.to_owned();
(basename, false)
}
};
match PluginName::from_str(&candidate) {
Ok(_) => Ok(candidate),
Err(SchemaError::ReservedPluginName { .. }) if source_was_explicit => {
Err(CliError::usage(JsonError {
code: "usage::invalid_name".into(),
message: format!(
"--name {candidate:?} is a Windows reserved device name \
(case-insensitive); pick a different name"
),
field: None,
details: Some(serde_json::json!({
"value": candidate,
"reason": "reserved_name",
})),
diagnostics: vec![],
cause: vec![],
}))
}
Err(_) if source_was_explicit => Err(CliError::usage(JsonError {
code: "usage::invalid_name".into(),
message: format!("--name {candidate:?} is not a valid plugin name; {PLUGIN_NAME_RULE}"),
field: None,
details: Some(serde_json::json!({
"value": candidate,
"reason": "invalid_format",
})),
diagnostics: vec![],
cause: vec![],
})),
Err(SchemaError::ReservedPluginName { .. }) => {
let dir_display = absolutize_for_json(dir)?.display().to_string();
Err(CliError::runtime(JsonError {
code: "new::derived_name_invalid".into(),
message: format!(
"derived plugin name {candidate:?} (from path basename) is a \
Windows reserved device name; pass --name <name> explicitly"
),
field: Some(dir_display),
details: None,
diagnostics: vec![],
cause: vec![],
}))
}
Err(_) => {
let dir_display = absolutize_for_json(dir)?.display().to_string();
Err(CliError::runtime(JsonError {
code: "new::derived_name_invalid".into(),
message: format!(
"derived plugin name {candidate:?} (from path basename) is not a valid \
plugin name; pass --name <name> explicitly. {PLUGIN_NAME_RULE}"
),
field: Some(dir_display),
details: None,
diagnostics: vec![],
cause: vec![],
}))
}
}
}
const PLUGIN_NAME_RULE: &str = "plugin names must match `[a-zA-Z][a-zA-Z0-9_-]*` (1-64 chars, ASCII \
alphanumerics / `-` / `_`, starting with a letter)";
fn check_sibling_canonical_collision(
parent: &Path,
resolved_name: &str,
) -> Result<(), anyhow::Error> {
let Ok(target_canonical) = PluginName::from_str(resolved_name).map(|p| p.canonical()) else {
return Ok(());
};
let read_dir = match std::fs::read_dir(parent) {
Ok(rd) => rd,
Err(_) => return Ok(()),
};
for entry in read_dir.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(basename) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
let Ok(sibling_name) = PluginName::from_str(basename) else {
continue;
};
if sibling_name.canonical() == target_canonical && sibling_name.as_str() != resolved_name {
return Err(CliError::usage(JsonError {
code: "usage::sibling_canonical_collision".into(),
message: format!(
"plugin name {resolved_name:?} canonically collides with existing \
sibling directory {basename:?} (both normalize to {target_canonical:?}). \
Rename the new plugin or use the existing spelling."
),
field: None,
details: Some(serde_json::json!({
"name": resolved_name,
"sibling": basename,
"canonical": target_canonical,
})),
diagnostics: vec![],
cause: vec![],
}));
}
}
Ok(())
}
#[derive(Debug)]
struct Summary {
kind: SummaryKind,
template: &'static TemplateMetadata,
target_dir: PathBuf,
name: Option<String>,
files_written: Vec<PathBuf>,
}
#[derive(Debug, Clone, Copy)]
enum SummaryKind {
Plugin,
Index,
}
impl SummaryKind {
fn as_str(self) -> &'static str {
match self {
Self::Plugin => "plugin",
Self::Index => "index",
}
}
}
fn render(summary: &Summary, mode: OutputMode, stdout_palette: Palette) -> anyhow::Result<()> {
match mode {
OutputMode::Human => render_human(summary, stdout_palette, &mut std::io::stdout())?,
OutputMode::Json => render_json(summary, &mut std::io::stdout())?,
}
Ok(())
}
fn render_human(
summary: &Summary,
palette: Palette,
writer: &mut impl std::io::Write,
) -> std::io::Result<()> {
let kind = summary.kind.as_str();
let template = summary.template.short_name;
let ok = palette.success.render();
let ok_reset = palette.success.render_reset();
writeln!(
writer,
"{ok}Scaffolded {kind} ({template} template) at {}{ok_reset}",
display_relative_to_cwd(&summary.target_dir)
)?;
if let Some(name) = &summary.name {
writeln!(writer, " name: {name}")?;
}
writeln!(writer, " files written:")?;
for file in &summary.files_written {
writeln!(writer, " {}", display_relative_to_cwd(file))?;
}
Ok(())
}
fn render_json(summary: &Summary, writer: &mut impl std::io::Write) -> anyhow::Result<()> {
let payload = NewOutput {
kind: summary.kind.as_str(),
template: summary.template.short_name,
target_dir: summary.target_dir.clone(),
name: summary.name.clone(),
files_written: summary.files_written.clone(),
};
write_envelope_ok(writer, payload)?;
Ok(())
}