use crate::error::{Result, ThingsError};
use crate::models::{BulkOperationResult, ThingsId};
#[allow(dead_code)] pub(crate) fn extract_id(stdout: &str) -> Result<ThingsId> {
let trimmed = stdout.trim();
if !trimmed.is_empty() && !trimmed.contains('"') {
return Ok(ThingsId::from_trusted(trimmed.to_string()));
}
if let Some(start) = trimmed.find("id \"") {
let after = &trimmed[start + 4..];
if let Some(end) = after.find('"') {
let candidate = &after[..end];
if !candidate.is_empty() {
return Ok(ThingsId::from_trusted(candidate.to_string()));
}
}
}
Err(ThingsError::applescript(format!(
"could not extract ID from osascript output: {trimmed:?}"
)))
}
#[allow(dead_code)] pub(crate) fn parse_bulk_result(stdout: &str, total: usize) -> Result<BulkOperationResult> {
let trimmed = stdout.trim();
let mut lines = trimmed.lines();
let header = lines.next().unwrap_or("");
let processed: usize = header
.strip_prefix("OK ")
.and_then(|s| s.trim().parse().ok())
.ok_or_else(|| {
ThingsError::applescript(format!(
"bulk script returned unexpected output: {trimmed:?}"
))
})?;
let processed = processed.min(total);
let errors: Vec<String> = lines.map(|l| l.trim().to_string()).collect();
let success = errors.is_empty();
let message = if success {
format!("Successfully processed {processed} item(s)")
} else {
format!(
"Processed {processed}/{total}; errors: {}",
errors.join("; ")
)
};
Ok(BulkOperationResult {
success,
processed_count: processed,
message,
})
}
pub(crate) fn parse_atomic_bulk_create_result(stdout: &str) -> Result<BulkOperationResult> {
let trimmed = stdout.trim();
if let Some(msg) = trimmed.strip_prefix("ROLLBACK: ") {
return Err(ThingsError::applescript(format!(
"bulk_create_tasks rolled back after partial failure: {msg}"
)));
}
let processed: usize = trimmed
.strip_prefix("OK ")
.and_then(|s| s.trim().parse().ok())
.ok_or_else(|| {
ThingsError::applescript(format!(
"bulk_create_tasks returned unexpected output: {trimmed:?}"
))
})?;
Ok(BulkOperationResult {
success: true,
processed_count: processed,
message: format!("Successfully created {processed} task(s)"),
})
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_UUID: &str = "9d3f1e44-5c2a-4b8e-9c1f-7e2d8a4b3c5e";
const SAMPLE_THINGS_ID: &str = "R4t2G8Q63aGZq4epMHNeCr";
#[test]
fn extracts_bare_uuid() {
let id = extract_id(SAMPLE_UUID).unwrap();
assert_eq!(id.as_str(), SAMPLE_UUID);
}
#[test]
fn extracts_bare_things_id() {
let id = extract_id(SAMPLE_THINGS_ID).unwrap();
assert_eq!(id.as_str(), SAMPLE_THINGS_ID);
}
#[test]
fn extracts_bare_uuid_with_trailing_newline() {
let stdout = format!("{SAMPLE_UUID}\n");
let id = extract_id(&stdout).unwrap();
assert_eq!(id.as_str(), SAMPLE_UUID);
}
#[test]
fn extracts_bare_uuid_with_surrounding_whitespace() {
let stdout = format!(" {SAMPLE_UUID} \n");
let id = extract_id(&stdout).unwrap();
assert_eq!(id.as_str(), SAMPLE_UUID);
}
#[test]
fn extracts_uuid_from_things_reference() {
let stdout = format!("to do id \"{SAMPLE_UUID}\" of application \"Things3\"\n");
let id = extract_id(&stdout).unwrap();
assert_eq!(id.as_str(), SAMPLE_UUID);
}
#[test]
fn extracts_first_uuid_from_multiple_references() {
let second = "11111111-2222-3333-4444-555555555555";
let stdout = format!(
"to do id \"{SAMPLE_UUID}\" of application \"Things3\", \
to do id \"{second}\" of application \"Things3\""
);
let id = extract_id(&stdout).unwrap();
assert_eq!(id.as_str(), SAMPLE_UUID);
}
#[test]
fn accepts_bare_non_uuid_string_intentionally() {
let id = extract_id("not a uuid at all").unwrap();
assert_eq!(id.as_str(), "not a uuid at all");
}
#[test]
fn rejects_empty_input() {
let err = extract_id("").unwrap_err();
match err {
ThingsError::AppleScript { message } => {
assert!(message.contains("could not extract ID"));
}
_ => panic!("expected AppleScript error, got {err:?}"),
}
}
#[test]
fn extracts_things_id_from_reference() {
let stdout = format!("to do id \"{SAMPLE_THINGS_ID}\" of application \"Things3\"\n");
let id = extract_id(&stdout).unwrap();
assert_eq!(id.as_str(), SAMPLE_THINGS_ID);
}
#[test]
fn parse_bulk_all_success() {
let res = parse_bulk_result("OK 5\n", 5).unwrap();
assert!(res.success);
assert_eq!(res.processed_count, 5);
assert!(res.message.contains("Successfully processed 5"));
}
#[test]
fn parse_bulk_partial_failure() {
let stdout = "OK 3\nitem 1: not found\nitem 4: invalid";
let res = parse_bulk_result(stdout, 5).unwrap();
assert!(!res.success);
assert_eq!(res.processed_count, 3);
assert!(res.message.contains("3/5"));
assert!(res.message.contains("item 1: not found"));
assert!(res.message.contains("item 4: invalid"));
}
#[test]
fn parse_bulk_zero_items() {
let res = parse_bulk_result("OK 0\n", 0).unwrap();
assert!(res.success);
assert_eq!(res.processed_count, 0);
}
#[test]
fn parse_bulk_clamps_processed_to_total() {
let res = parse_bulk_result("OK 10\n", 5).unwrap();
assert!(res.success);
assert_eq!(res.processed_count, 5);
}
#[test]
fn parse_bulk_rejects_garbage_header() {
let err = parse_bulk_result("garbage", 1).unwrap_err();
match err {
ThingsError::AppleScript { message } => {
assert!(message.contains("unexpected output"));
}
_ => panic!("expected AppleScript error, got {err:?}"),
}
}
#[test]
fn parse_atomic_bulk_create_zero() {
let res = parse_atomic_bulk_create_result("OK 0").unwrap();
assert!(res.success);
assert_eq!(res.processed_count, 0);
}
#[test]
fn parse_atomic_bulk_create_success() {
let res = parse_atomic_bulk_create_result("OK 3").unwrap();
assert!(res.success);
assert_eq!(res.processed_count, 3);
assert!(res.message.contains("Successfully created 3"));
}
#[test]
fn parse_atomic_bulk_create_rollback_returns_err() {
let err =
parse_atomic_bulk_create_result("ROLLBACK: project id \"bad-uuid\" doesn't exist")
.unwrap_err();
match err {
ThingsError::AppleScript { message } => {
assert!(message.contains("rolled back"));
assert!(message.contains("bad-uuid"));
}
_ => panic!("expected AppleScript error, got {err:?}"),
}
}
#[test]
fn parse_atomic_bulk_create_rejects_garbage() {
let err = parse_atomic_bulk_create_result("garbage").unwrap_err();
match err {
ThingsError::AppleScript { message } => {
assert!(message.contains("unexpected output"));
}
_ => panic!("expected AppleScript error, got {err:?}"),
}
}
}