use std::time::{SystemTime, UNIX_EPOCH};
use crate::assignee::parse_assignee;
use crate::commands::{parse_priority, parse_selector};
use crate::config::Config;
use crate::editor::{FieldEdits, merge_fields, resolve_editor, run_editor};
use crate::error::{Result, TixError};
use crate::model::Issue;
use crate::storage::Store;
#[allow(clippy::too_many_arguments)]
pub fn run(
config: &Config,
store: &Store,
raw: &str,
title: Option<&str>,
body: Option<&str>,
status: Option<&str>,
priority: Option<&str>,
assignee: Option<&str>,
labels: Option<&str>,
links: Option<&str>,
files: Option<&str>,
estimate: Option<&str>,
commit: Option<&str>,
) -> Result<()> {
if title.is_some()
|| body.is_some()
|| status.is_some()
|| priority.is_some()
|| assignee.is_some()
|| labels.is_some()
|| links.is_some()
|| files.is_some()
|| estimate.is_some()
|| commit.is_some()
{
let issue = apply(
config, store, raw, title, body, status, priority, assignee, labels, links, files,
estimate, commit,
)?;
println!("Updated {}", issue.filename);
} else {
let selector = parse_selector(store, raw)?;
let issue = store.get(selector)?;
edit_with_editor(config, store, &issue)?;
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn apply(
_config: &Config,
store: &Store,
raw: &str,
title: Option<&str>,
body: Option<&str>,
status: Option<&str>,
priority: Option<&str>,
assignee: Option<&str>,
labels: Option<&str>,
links: Option<&str>,
files: Option<&str>,
estimate: Option<&str>,
commit: Option<&str>,
) -> Result<Issue> {
let selector = parse_selector(store, raw)?;
let issue = store.get(selector)?;
if let Some(p) = priority {
parse_priority(p)?;
}
let edits = FieldEdits {
title: title.map(String::from),
status: status.map(String::from),
priority: priority.map(String::from),
labels: labels.map(|l| {
l.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}),
files: files.map(|f| {
f.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}),
assignee: assignee.map(parse_assignee),
estimate: estimate.map(|e| Some(e.to_string())),
commit: commit.map(|c| {
if c == "clear" {
None
} else {
Some(c.to_string())
}
}),
};
let mut merged = merge_fields(&issue, &edits);
let mut links_value: Vec<String> = issue.links.clone();
if let Some(l) = links {
let linked = store.set_links(&issue.filename, crate::commands::parse_link_numbers(l)?)?;
links_value = linked.links;
}
merged.links = links_value;
if let Some(b) = body {
merged.body = b.to_string();
}
let mut filename: String = issue.filename.clone();
if assignee.is_some() {
let moved = store.reassign(&issue.filename, merged.assignee.as_deref())?;
filename = moved.to_string_lossy().to_string();
}
store.update(&filename, &merged)?;
merged.filename = filename;
Ok(merged)
}
fn edit_with_editor(config: &Config, store: &Store, issue: &Issue) -> Result<()> {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let temp = std::env::temp_dir().join(format!("tix-edit-{}-{nanos}.md", std::process::id()));
std::fs::write(&temp, issue.to_file_contents()).map_err(TixError::Io)?;
let result = (|| -> Result<()> {
let editor = resolve_editor(config.editor.as_deref())?;
run_editor(&editor, &temp, config)?;
let edited = std::fs::read_to_string(&temp).map_err(TixError::Io)?;
let mut parsed = Issue::parse(&edited)?;
if parsed.title.trim().is_empty() {
return Err(TixError::Editor("title is required".into()));
}
parsed.bump_updated();
store.update(&issue.filename, &parsed)?;
Ok(())
})();
let _ = std::fs::remove_file(&temp);
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::create::create;
use crate::model::Priority;
use std::fs;
use tempfile::TempDir;
fn make_store() -> (TempDir, Store) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config_path = tmp.path().join("tix.toml");
let config = crate::config::Config {
issues_dir: dir.clone(),
..crate::config::Config::default()
};
config.save(&config_path).unwrap();
let config = crate::config::Config::load_with_warning(&config_path, |_| {}).unwrap();
(
tmp,
Store {
issues_dir: dir,
config,
},
)
}
fn seed(store: &Store, assignee: &str) -> Issue {
create(
&store.config,
store,
"Seed issue",
Some("body"),
None,
None,
Some(assignee),
None,
None,
None,
None,
)
.unwrap()
}
#[test]
fn apply_updates_fields_and_returns_updated_issue() {
let (tmp, store) = make_store();
let _ = tmp;
let seeded = seed(&store, "@alice");
let updated = apply(
&store.config,
&store,
&seeded.number.to_string(),
Some("Renamed"),
Some("new body"),
Some("in progress"),
Some("high"),
None,
Some("bug,urgent"),
None,
None,
Some("2"),
Some("abc123"),
)
.unwrap();
assert_eq!(updated.title, "Renamed");
assert_eq!(updated.body, "new body");
assert_eq!(updated.status.display(), "in progress");
assert_eq!(updated.priority, Priority::High);
assert_eq!(
updated.labels,
vec!["bug".to_string(), "urgent".to_string()]
);
assert_eq!(updated.estimate.as_deref(), Some("2"));
assert_eq!(updated.commit.as_deref(), Some("abc123"));
assert_eq!(updated.filename, seeded.filename);
let reloaded = store
.get(crate::storage::IssueSelector::Filename(
updated.filename.clone(),
))
.unwrap();
assert_eq!(reloaded.title, "Renamed");
assert_eq!(reloaded.body, "new body");
}
#[test]
fn apply_reassign_moves_file_between_segments() {
let (tmp, store) = make_store();
let _ = tmp;
let seeded = seed(&store, "alice");
let updated = apply(
&store.config,
&store,
&seeded.number.to_string(),
None,
None,
None,
None,
Some("@bob"),
None,
None,
None,
None,
None,
)
.unwrap();
assert!(
updated.filename.starts_with("bob/"),
"file must move into the new segment, got {}",
updated.filename
);
assert_eq!(updated.number, seeded.number, "number is stable");
assert!(!store.issues_dir.join(&seeded.filename).exists());
assert!(store.issues_dir.join(&updated.filename).is_file());
}
#[test]
fn apply_commit_clear_resets_commit_field() {
let (tmp, store) = make_store();
let _ = tmp;
let seeded = seed(&store, "alice");
let with_commit = apply(
&store.config,
&store,
&seeded.number.to_string(),
None,
None,
None,
None,
None,
None,
None,
None,
None,
Some("abc123"),
)
.unwrap();
assert_eq!(with_commit.commit.as_deref(), Some("abc123"));
let cleared = apply(
&store.config,
&store,
&seeded.number.to_string(),
None,
None,
None,
None,
None,
None,
None,
None,
None,
Some("clear"),
)
.unwrap();
assert_eq!(cleared.commit, None);
}
#[test]
fn apply_rejects_invalid_priority_with_frozen_message() {
let (tmp, store) = make_store();
let _ = tmp;
let seeded = seed(&store, "alice");
let err = apply(
&store.config,
&store,
&seeded.number.to_string(),
None,
None,
None,
Some("bogus"),
None,
None,
None,
None,
None,
None,
)
.unwrap_err();
assert_eq!(
err.to_string(),
"invalid input: invalid priority 'bogus' — use low, medium or high"
);
}
#[test]
fn apply_replaces_links_and_reconciles_back_references() {
let (tmp, store) = make_store();
let _ = tmp;
let a = seed(&store, "alice");
let b = seed(&store, "bob");
let c = seed(&store, "carol");
seed(&store, "dave");
let linked = apply(
&store.config,
&store,
&a.number.to_string(),
None,
None,
None,
None,
None,
None,
Some(&format!("{},{}", b.number, c.number)),
None,
None,
None,
)
.unwrap();
assert_eq!(
linked.links,
vec![b.number.to_string(), c.number.to_string()]
);
let reloaded_b = store
.get(crate::storage::IssueSelector::Number(b.number))
.unwrap();
assert_eq!(reloaded_b.links, [a.number.to_string()]);
}
#[test]
fn apply_clearing_links_drops_all_back_references() {
let (tmp, store) = make_store();
let _ = tmp;
let a = seed(&store, "alice");
let b = seed(&store, "bob");
let linked = apply(
&store.config,
&store,
&a.number.to_string(),
None,
None,
None,
None,
None,
None,
Some(&b.number.to_string()),
None,
None,
None,
)
.unwrap();
assert_eq!(linked.links, [b.number.to_string()]);
let cleared = apply(
&store.config,
&store,
&a.number.to_string(),
None,
None,
None,
None,
None,
None,
Some(""),
None,
None,
None,
)
.unwrap();
assert!(cleared.links.is_empty());
let reloaded_b = store
.get(crate::storage::IssueSelector::Number(b.number))
.unwrap();
assert!(reloaded_b.links.is_empty());
}
#[test]
fn apply_sets_files_list() {
let (tmp, store) = make_store();
let _ = tmp;
let a = seed(&store, "alice");
let updated = apply(
&store.config,
&store,
&a.number.to_string(),
None,
None,
None,
None,
None,
None,
None,
Some("specs/a.md, specs/b.md"),
None,
None,
)
.unwrap();
assert_eq!(updated.files, ["specs/a.md", "specs/b.md"]);
let cleared = apply(
&store.config,
&store,
&a.number.to_string(),
None,
None,
None,
None,
None,
None,
None,
Some(""),
None,
None,
)
.unwrap();
assert!(cleared.files.is_empty());
}
}