use std::io::{IsTerminal, Read};
use crate::cli::BugAction;
use crate::client::BugzillaClient;
use crate::commands::editor;
use crate::error::Result;
use crate::output::result_types::{write_result, ActionResult, ResourceKind};
use crate::output::writers::Writers;
use crate::types::{CreateBugParams, OutputFormat};
fn read_description_file(path: &std::path::Path) -> Result<String> {
std::fs::read_to_string(path).map_err(|e| {
crate::error::BzrError::InputValidation(format!(
"--description-file could not be read ({}): {e}",
path.display()
))
})
}
const SENTINEL: &str = "# ------------------------ >8 ------------------------";
struct MergedFields {
product: String,
component: String,
version: Option<String>,
priority: Option<String>,
severity: Option<String>,
assigned_to: Option<String>,
op_sys: Option<String>,
rep_platform: Option<String>,
template_description: Option<String>,
}
impl MergedFields {
fn preview_params(&self) -> CreateBugParams {
CreateBugParams {
product: self.product.clone(),
component: self.component.clone(),
summary: String::new(),
version: self
.version
.clone()
.unwrap_or_else(|| "unspecified".to_string()),
description: None,
priority: self.priority.clone(),
severity: self.severity.clone(),
assigned_to: self.assigned_to.clone(),
op_sys: self.op_sys.clone(),
rep_platform: self.rep_platform.clone(),
blocks: vec![],
depends_on: vec![],
cc: vec![],
keywords: vec![],
}
}
}
fn parse_editor_buffer(raw: &str) -> Result<(String, String)> {
let mut iter = raw
.lines()
.take_while(|l| l.trim_end() != SENTINEL)
.skip_while(|l| l.trim().is_empty());
let summary = iter
.next()
.map(|l| l.trim().to_string())
.ok_or_else(|| crate::error::BzrError::InputValidation("empty buffer, aborting".into()))?;
let body: Vec<&str> = iter.collect();
let start = body.iter().position(|l| !l.trim().is_empty()).unwrap_or(0);
let end = body
.iter()
.rposition(|l| !l.trim().is_empty())
.map_or(0, |i| i + 1);
let description = body
.get(start..end)
.map(|s| s.join("\n"))
.unwrap_or_default();
Ok((summary, description))
}
fn build_editor_template(
summary_pre_fill: Option<&str>,
template_description: Option<&str>,
params: &CreateBugParams,
) -> String {
let mut buf = String::new();
buf.push_str(summary_pre_fill.unwrap_or(""));
buf.push('\n');
buf.push('\n');
if let Some(body) = template_description {
buf.push_str(body);
if !body.ends_with('\n') {
buf.push('\n');
}
buf.push('\n');
}
buf.push_str(SENTINEL);
buf.push('\n');
buf.push_str("# Do not modify or remove the line above.\n");
buf.push_str("# Everything below it will be ignored.\n");
buf.push_str("#\n");
let row = |label: &str, val: &str| format!("# {label:<12}{val}\n");
let unset = "<unset>";
buf.push_str(&row("Product:", ¶ms.product));
buf.push_str(&row("Component:", ¶ms.component));
buf.push_str(&row("Version:", ¶ms.version));
buf.push_str(&row(
"Priority:",
params.priority.as_deref().unwrap_or(unset),
));
buf.push_str(&row(
"Severity:",
params.severity.as_deref().unwrap_or(unset),
));
buf.push_str(&row(
"Assignee:",
params.assigned_to.as_deref().unwrap_or(unset),
));
buf.push_str(&row("OpSys:", params.op_sys.as_deref().unwrap_or(unset)));
buf.push_str(&row(
"Platform:",
params.rep_platform.as_deref().unwrap_or(unset),
));
buf
}
fn resolve_description(
description: Option<&str>,
description_file: Option<&std::path::Path>,
) -> Result<Option<String>> {
if let Some(d) = description {
return Ok(Some(d.to_owned()));
}
if let Some(p) = description_file {
return Ok(Some(read_description_file(p)?));
}
if !std::io::stdin().is_terminal() {
let mut buf = String::new();
std::io::stdin().lock().read_to_string(&mut buf)?;
if buf.trim().is_empty() {
return Err(crate::error::BzrError::InputValidation(
"no description supplied (piped stdin is empty)".into(),
));
}
return Ok(Some(buf));
}
Ok(None)
}
fn load_template(name: Option<&str>) -> Result<Option<crate::types::BugTemplate>> {
let Some(name) = name else { return Ok(None) };
let config = crate::config::Config::load()?;
let t = config
.templates
.get(name)
.ok_or_else(|| crate::error::BzrError::config(format!("template '{name}' not found")))?;
Ok(Some(t.clone()))
}
fn run_editor_flow(
summary_pre_fill: Option<&str>,
merged: &MergedFields,
) -> Result<(String, String)> {
let preview = merged.preview_params();
let template_buf = build_editor_template(
summary_pre_fill,
merged.template_description.as_deref(),
&preview,
);
let raw = editor::launch(&template_buf, "bug-create")?;
parse_editor_buffer(&raw)
}
fn merge_fields(
action: &BugAction,
tmpl: Option<&crate::types::BugTemplate>,
) -> Result<MergedFields> {
let BugAction::Create {
product,
component,
version,
priority,
severity,
assignee,
op_sys,
rep_platform,
..
} = action
else {
unreachable!()
};
let resolved_product = product
.clone()
.or_else(|| tmpl.and_then(|t| t.product.clone()))
.ok_or_else(|| {
crate::error::BzrError::InputValidation(
"--product is required (provide it directly or via a template)".into(),
)
})?;
let resolved_component = component
.clone()
.or_else(|| tmpl.and_then(|t| t.component.clone()))
.ok_or_else(|| {
crate::error::BzrError::InputValidation(
"--component is required (provide it directly or via a template)".into(),
)
})?;
Ok(MergedFields {
product: resolved_product,
component: resolved_component,
version: version
.clone()
.or_else(|| tmpl.and_then(|t| t.version.clone())),
priority: priority
.clone()
.or_else(|| tmpl.and_then(|t| t.priority.clone())),
severity: severity
.clone()
.or_else(|| tmpl.and_then(|t| t.severity.clone())),
assigned_to: assignee
.clone()
.or_else(|| tmpl.and_then(|t| t.assignee.clone())),
op_sys: op_sys
.clone()
.or_else(|| tmpl.and_then(|t| t.op_sys.clone())),
rep_platform: rep_platform
.clone()
.or_else(|| tmpl.and_then(|t| t.rep_platform.clone())),
template_description: tmpl.and_then(|t| t.description.clone()),
})
}
pub(super) async fn handle(
client: &BugzillaClient,
action: &BugAction,
format: OutputFormat,
w: &mut Writers<'_>,
) -> Result<()> {
let BugAction::Create {
template: template_name,
summary,
description,
description_file,
blocks,
depends_on,
..
} = action
else {
unreachable!()
};
let resolved_description =
resolve_description(description.as_deref(), description_file.as_deref())?;
let editor_flow_active = resolved_description.is_none();
let tmpl = load_template(template_name.as_deref())?;
let merged = merge_fields(action, tmpl.as_ref())?;
let (resolved_summary, final_description): (Option<String>, Option<String>) =
if editor_flow_active {
let (parsed_summary, parsed_description) =
run_editor_flow(summary.as_deref(), &merged)?;
(Some(parsed_summary), Some(parsed_description))
} else {
(summary.clone(), resolved_description)
};
let params = CreateBugParams {
product: merged.product,
component: merged.component,
summary: resolved_summary.ok_or_else(|| {
crate::error::BzrError::InputValidation(
"--summary is required (or run interactively without --description, --description-file, or piped stdin to compose in $EDITOR)"
.into(),
)
})?,
version: merged.version.unwrap_or_else(|| "unspecified".to_string()),
description: final_description,
priority: merged.priority,
severity: merged.severity,
assigned_to: merged.assigned_to,
op_sys: merged.op_sys,
rep_platform: merged.rep_platform,
blocks: blocks.clone(),
depends_on: depends_on.clone(),
cc: vec![],
keywords: vec![],
};
let id = client.create_bug(¶ms).await?;
write_result(
&ActionResult::created(id, ResourceKind::Bug),
&format!("Created bug #{id}"),
format,
w.out,
);
Ok(())
}
#[cfg(test)]
#[path = "create_tests.rs"]
mod tests;