use std::fs;
use std::io::{self, Read, Write};
use anyhow::{Context, Result};
use crate::atlassian::adf::AdfDocument;
use crate::atlassian::api::AtlassianApi;
use crate::atlassian::auth;
use crate::atlassian::client::AtlassianClient;
use crate::atlassian::convert::markdown_to_adf;
use crate::atlassian::document::{content_item_to_document, JfmDocument};
use super::format::ContentFormat;
pub async fn execute_read(
id: &str,
output: Option<&str>,
format: &ContentFormat,
api: &dyn AtlassianApi,
instance_url: &str,
) -> Result<()> {
let item = api.get_content(id).await?;
match format {
ContentFormat::Adf => {
let json =
serde_json::to_string_pretty(&item.body_adf.unwrap_or(serde_json::Value::Null))
.context("Failed to serialize ADF JSON")?;
output_text(&json, output)?;
}
ContentFormat::Jfm => {
let doc = content_item_to_document(&item, instance_url)?;
let rendered = doc.render()?;
output_text(&rendered, output)?;
if let Some(path) = output {
eprintln!("Saved to: {path}");
}
}
}
Ok(())
}
pub fn prepare_write(file: Option<&str>, format: &ContentFormat) -> Result<(AdfDocument, String)> {
let input = read_input(file)?;
match format {
ContentFormat::Jfm => {
let doc = JfmDocument::parse(&input)?;
let adf = markdown_to_adf(&doc.body)?;
let title = doc.frontmatter.title().to_string();
Ok((adf, title))
}
ContentFormat::Adf => {
let adf: AdfDocument =
serde_json::from_str(&input).context("Failed to parse ADF JSON input")?;
Ok((adf, String::new()))
}
}
}
pub fn print_dry_run(id: &str, adf: &AdfDocument, title: &str) -> Result<()> {
println!("Dry run for {id}:");
if !title.is_empty() {
println!(" Title: {title}");
}
println!(
"\nADF output:\n{}",
serde_json::to_string_pretty(adf).context("Failed to serialize ADF")?
);
Ok(())
}
pub fn print_create_dry_run(
project: &str,
issue_type: &str,
summary: &str,
adf: &AdfDocument,
labels: &[String],
) -> Result<()> {
println!("Dry run — would create issue:");
println!(" Project: {project}");
println!(" Type: {issue_type}");
println!(" Summary: {summary}");
if !labels.is_empty() {
println!(" Labels: {}", labels.join(", "));
}
println!(
"\nADF body:\n{}",
serde_json::to_string_pretty(adf).context("Failed to serialize ADF")?
);
Ok(())
}
pub async fn execute_write(
id: &str,
adf: &AdfDocument,
title: &str,
force: bool,
api: &dyn AtlassianApi,
) -> Result<()> {
if !force {
println!("About to update {id}:");
if !title.is_empty() {
println!(" Title: {title}");
}
print!("\nApply changes? [y/N] ");
io::stdout().flush()?;
let mut answer = String::new();
io::stdin().read_line(&mut answer)?;
if !answer.trim().eq_ignore_ascii_case("y") {
println!("Cancelled.");
return Ok(());
}
}
let title_ref = if title.is_empty() { None } else { Some(title) };
api.update_content(id, adf, title_ref).await?;
println!("Updated {id} successfully.");
Ok(())
}
pub async fn execute_edit(id: &str, api: &dyn AtlassianApi, instance_url: &str) -> Result<()> {
use tracing::debug;
println!("Fetching {id}...");
let item = api.get_content(id).await?;
let original_title = item.title.clone();
let doc = content_item_to_document(&item, instance_url)?;
let original_content = doc.render()?;
let temp_dir = tempfile::tempdir()?;
let temp_file = temp_dir.path().join(format!("{id}.md"));
fs::write(&temp_file, &original_content)?;
println!("Saved to: {}", temp_file.display());
loop {
print!("\n[A]ccept, [S]how, [E]dit, or [Q]uit? [a/s/e/q] ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
match input.trim().to_lowercase().as_str() {
"a" | "accept" => {
let final_content =
fs::read_to_string(&temp_file).context("Failed to read temp file")?;
if final_content == original_content {
println!("No changes detected.");
return Ok(());
}
let final_doc = JfmDocument::parse(&final_content)?;
debug!(
"Parsed JFM document, body length: {} bytes",
final_doc.body.len()
);
let adf = markdown_to_adf(&final_doc.body)?;
debug!(
"Converted to ADF with {} top-level nodes",
adf.content.len()
);
if tracing::enabled!(tracing::Level::TRACE) {
let adf_json = serde_json::to_string_pretty(&adf)
.unwrap_or_else(|e| format!("<serialization error: {e}>"));
tracing::trace!("ADF payload:\n{adf_json}");
}
let title_changed = final_doc.frontmatter.title() != original_title;
let title_update = if title_changed {
Some(final_doc.frontmatter.title())
} else {
None
};
api.update_content(id, &adf, title_update).await?;
println!("Updated {id} successfully.");
return Ok(());
}
"s" | "show" => {
let content = fs::read_to_string(&temp_file).context("Failed to read temp file")?;
println!("\n{content}");
}
"e" | "edit" => {
open_editor(&temp_file)?;
}
"q" | "quit" => {
println!("Cancelled.");
return Ok(());
}
_ => {
println!(
"Invalid choice. Enter 'a' to accept, 's' to show, 'e' to edit, or 'q' to quit."
);
}
}
}
}
pub fn create_client() -> Result<(AtlassianClient, String)> {
let credentials = auth::load_credentials()?;
let client = AtlassianClient::from_credentials(&credentials)?;
let instance_url = client.instance_url().to_string();
Ok((client, instance_url))
}
pub fn output_text(text: &str, file: Option<&str>) -> Result<()> {
match file {
Some(path) => {
fs::write(path, text).with_context(|| format!("Failed to write to {path}"))?;
}
None => {
print!("{text}");
}
}
Ok(())
}
pub fn read_input(file: Option<&str>) -> Result<String> {
match file {
Some("-") | None => {
let mut buf = String::new();
io::stdin()
.read_to_string(&mut buf)
.context("Failed to read from stdin")?;
Ok(buf)
}
Some(path) => {
fs::read_to_string(path).with_context(|| format!("Failed to read file: {path}"))
}
}
}
fn open_editor(file: &std::path::Path) -> Result<()> {
use std::env;
use std::process::Command;
let editor = if let Ok(e) = env::var("OMNI_DEV_EDITOR").or_else(|_| env::var("EDITOR")) {
e
} else {
print!("Neither OMNI_DEV_EDITOR nor EDITOR is set. Enter editor command: ");
io::stdout().flush().context("Failed to flush stdout")?;
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.context("Failed to read user input")?;
input.trim().to_string()
};
if editor.is_empty() {
println!("No editor specified. Returning to menu.");
return Ok(());
}
let (editor_cmd, args) = crate::cli::git::formatting::parse_editor_command(&editor);
let mut command = Command::new(editor_cmd);
command.args(args);
command.arg(file.to_string_lossy().as_ref());
match command.status() {
Ok(status) => {
if status.success() {
println!("Editor session completed.");
} else {
println!("Editor exited with non-zero status: {:?}", status.code());
}
}
Err(e) => {
println!("Failed to execute editor '{editor}': {e}");
}
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::atlassian::api::{ContentItem, ContentMetadata};
struct MockApi {
content: ContentItem,
update_called: std::sync::Mutex<bool>,
}
impl MockApi {
fn jira_issue(body_adf: Option<serde_json::Value>) -> Self {
Self {
content: ContentItem {
id: "PROJ-1".to_string(),
title: "Test Issue".to_string(),
body_adf,
metadata: ContentMetadata::Jira {
status: Some("Open".to_string()),
issue_type: Some("Bug".to_string()),
assignee: None,
priority: None,
labels: vec![],
},
},
update_called: std::sync::Mutex::new(false),
}
}
fn confluence_page(body_adf: Option<serde_json::Value>) -> Self {
Self {
content: ContentItem {
id: "12345".to_string(),
title: "Test Page".to_string(),
body_adf,
metadata: ContentMetadata::Confluence {
space_key: "ENG".to_string(),
status: Some("current".to_string()),
version: Some(1),
parent_id: None,
},
},
update_called: std::sync::Mutex::new(false),
}
}
fn was_update_called(&self) -> bool {
*self.update_called.lock().unwrap()
}
}
impl AtlassianApi for MockApi {
fn get_content<'a>(
&'a self,
_id: &'a str,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ContentItem>> + Send + 'a>>
{
Box::pin(async { Ok(self.content.clone()) })
}
fn update_content<'a>(
&'a self,
_id: &'a str,
_body_adf: &'a AdfDocument,
_title: Option<&'a str>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
*self.update_called.lock().unwrap() = true;
Box::pin(async { Ok(()) })
}
fn verify_auth<'a>(
&'a self,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>>
{
Box::pin(async { Ok("Test User".to_string()) })
}
fn backend_name(&self) -> &'static str {
"mock"
}
}
#[test]
fn output_text_to_file() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("output.txt");
let path_str = file_path.to_str().unwrap();
output_text("hello world", Some(path_str)).unwrap();
let content = fs::read_to_string(&file_path).unwrap();
assert_eq!(content, "hello world");
}
#[test]
fn output_text_to_stdout() {
assert!(output_text("test", None).is_ok());
}
#[test]
fn output_text_overwrites_existing_file() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("output.txt");
let path_str = file_path.to_str().unwrap();
fs::write(&file_path, "old content").unwrap();
output_text("new content", Some(path_str)).unwrap();
let content = fs::read_to_string(&file_path).unwrap();
assert_eq!(content, "new content");
}
#[test]
fn output_text_invalid_path() {
let result = output_text("data", Some("/nonexistent_dir/file.txt"));
assert!(result.is_err());
}
#[test]
fn read_input_from_file() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("issue.md");
let content = "---\ntype: jira\ninstance: https://org.atlassian.net\nkey: PROJ-1\nsummary: Test\n---\n\nBody\n";
fs::write(&file_path, content).unwrap();
let result = read_input(Some(file_path.to_str().unwrap())).unwrap();
assert_eq!(result, content);
}
#[test]
fn read_input_missing_file() {
let result = read_input(Some("/nonexistent/file.md"));
assert!(result.is_err());
}
#[test]
fn open_editor_with_true_command() {
std::env::set_var("OMNI_DEV_EDITOR", "true");
let temp_dir = tempfile::tempdir().unwrap();
let file = temp_dir.path().join("test.md");
fs::write(&file, "content").unwrap();
let result = open_editor(&file);
std::env::remove_var("OMNI_DEV_EDITOR");
assert!(result.is_ok());
}
#[test]
fn open_editor_with_nonexistent_command() {
std::env::set_var("OMNI_DEV_EDITOR", "nonexistent_editor_binary_12345");
let temp_dir = tempfile::tempdir().unwrap();
let file = temp_dir.path().join("test.md");
fs::write(&file, "content").unwrap();
let result = open_editor(&file);
std::env::remove_var("OMNI_DEV_EDITOR");
assert!(result.is_ok());
}
#[test]
fn prepare_write_jfm_format() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("issue.md");
let content = "---\ntype: jira\ninstance: https://org.atlassian.net\nkey: PROJ-1\nsummary: My Title\n---\n\nHello world\n";
fs::write(&file_path, content).unwrap();
let (adf, title) =
prepare_write(Some(file_path.to_str().unwrap()), &ContentFormat::Jfm).unwrap();
assert_eq!(title, "My Title");
assert!(!adf.content.is_empty());
}
#[test]
fn prepare_write_adf_format() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("issue.json");
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}]}"#;
fs::write(&file_path, adf_json).unwrap();
let (adf, title) =
prepare_write(Some(file_path.to_str().unwrap()), &ContentFormat::Adf).unwrap();
assert!(title.is_empty());
assert_eq!(adf.content.len(), 1);
}
#[test]
fn prepare_write_invalid_adf_json() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("bad.json");
fs::write(&file_path, "not json").unwrap();
let result = prepare_write(Some(file_path.to_str().unwrap()), &ContentFormat::Adf);
assert!(result.is_err());
}
#[test]
fn prepare_write_missing_file() {
let result = prepare_write(Some("/nonexistent/file.md"), &ContentFormat::Jfm);
assert!(result.is_err());
}
#[test]
fn print_dry_run_with_title() {
let adf = AdfDocument::new();
let result = print_dry_run("PROJ-1", &adf, "My Title");
assert!(result.is_ok());
}
#[test]
fn print_dry_run_without_title() {
let adf = AdfDocument::new();
let result = print_dry_run("PROJ-1", &adf, "");
assert!(result.is_ok());
}
#[tokio::test]
async fn execute_read_jfm_to_stdout() {
let adf_body = serde_json::json!({
"version": 1,
"type": "doc",
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "Hello"}]}]
});
let api = MockApi::jira_issue(Some(adf_body));
let result = execute_read(
"PROJ-1",
None,
&ContentFormat::Jfm,
&api,
"https://org.atlassian.net",
)
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn execute_read_adf_to_stdout() {
let adf_body = serde_json::json!({
"version": 1,
"type": "doc",
"content": []
});
let api = MockApi::jira_issue(Some(adf_body));
let result = execute_read(
"PROJ-1",
None,
&ContentFormat::Adf,
&api,
"https://org.atlassian.net",
)
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn execute_read_adf_null_body() {
let api = MockApi::jira_issue(None);
let result = execute_read(
"PROJ-1",
None,
&ContentFormat::Adf,
&api,
"https://org.atlassian.net",
)
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn execute_read_jfm_to_file() {
let adf_body = serde_json::json!({
"version": 1,
"type": "doc",
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "Hello"}]}]
});
let api = MockApi::jira_issue(Some(adf_body));
let temp_dir = tempfile::tempdir().unwrap();
let out_path = temp_dir.path().join("out.md");
let result = execute_read(
"PROJ-1",
Some(out_path.to_str().unwrap()),
&ContentFormat::Jfm,
&api,
"https://org.atlassian.net",
)
.await;
assert!(result.is_ok());
assert!(out_path.exists());
let content = fs::read_to_string(&out_path).unwrap();
assert!(content.contains("PROJ-1"));
}
#[tokio::test]
async fn execute_read_adf_to_file() {
let adf_body = serde_json::json!({
"version": 1,
"type": "doc",
"content": []
});
let api = MockApi::jira_issue(Some(adf_body));
let temp_dir = tempfile::tempdir().unwrap();
let out_path = temp_dir.path().join("out.json");
let result = execute_read(
"PROJ-1",
Some(out_path.to_str().unwrap()),
&ContentFormat::Adf,
&api,
"https://org.atlassian.net",
)
.await;
assert!(result.is_ok());
assert!(out_path.exists());
}
#[tokio::test]
async fn execute_read_confluence_jfm() {
let adf_body = serde_json::json!({
"version": 1,
"type": "doc",
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "Page content"}]}]
});
let api = MockApi::confluence_page(Some(adf_body));
let result = execute_read(
"12345",
None,
&ContentFormat::Jfm,
&api,
"https://org.atlassian.net",
)
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn execute_write_force_with_title() {
let api = MockApi::jira_issue(None);
let adf = AdfDocument::new();
let result = execute_write("PROJ-1", &adf, "My Title", true, &api).await;
assert!(result.is_ok());
assert!(api.was_update_called());
}
#[tokio::test]
async fn execute_write_force_empty_title() {
let api = MockApi::jira_issue(None);
let adf = AdfDocument::new();
let result = execute_write("PROJ-1", &adf, "", true, &api).await;
assert!(result.is_ok());
assert!(api.was_update_called());
}
#[test]
fn print_create_dry_run_with_labels() {
let adf = AdfDocument::new();
let labels = vec!["backend".to_string(), "urgent".to_string()];
let result = print_create_dry_run("PROJ", "Bug", "Fix login", &adf, &labels);
assert!(result.is_ok());
}
#[test]
fn print_create_dry_run_without_labels() {
let adf = AdfDocument::new();
let result = print_create_dry_run("PROJ", "Task", "Add feature", &adf, &[]);
assert!(result.is_ok());
}
}