diff --git a/lib/app/v3/champion/replica.dart b/lib/app/v3/champion/replica.dart index 0e7ece48f..6f0f0dda2 100644 --- a/lib/app/v3/champion/replica.dart +++ b/lib/app/v3/champion/replica.dart @@ -25,6 +25,20 @@ class Replica { // interprets. The desktop CLI is what acts on it. "recur", ]; + + /// Format a map value for the FFI string map. + /// + /// Dart's [DateTime.toString] uses a space (`2026-10-03 12:00:00.000Z`), + /// which Rust `parse_datetime` rejects. RFC3339 via [DateTime.toIso8601String] + /// keeps due/wait/start intact on create. + @visibleForTesting + static String attributeValueToString(dynamic value) { + if (value is DateTime) { + return value.toUtc().toIso8601String(); + } + return value.toString(); + } + /// Create a task, returning null on success or the reason it was refused. /// /// This used to answer with a success/failure sentinel that no caller read — @@ -46,7 +60,7 @@ class Replica { if (newTask.containsKey(attr) && newTask[attr] != null && attrs.contains(attr)) { - map[attr] = newTask[attr].toString(); + map[attr] = attributeValueToString(newTask[attr]); } } map['uuid'] = UuidV4().generate(); diff --git a/rust/src/api.rs b/rust/src/api.rs index f16fcc1ba..3d76d470a 100644 --- a/rust/src/api.rs +++ b/rust/src/api.rs @@ -11,10 +11,21 @@ use crate::storage::open_replica; use crate::utils::error::TcHelperError; fn parse_datetime(input: &str) -> Option> { - if input.trim().is_empty() { + let input = input.trim(); + if input.is_empty() { return None; } - input.parse::>().ok() + if let Ok(dt) = input.parse::>() { + return Some(dt); + } + // Dart's DateTime.toString() emits a space instead of 'T' + // (`2026-10-03 12:00:00.000Z`). That is not RFC3339, so accept it as a + // fallback — otherwise set_due/set_wait silently clear the field. + if let Some((date, rest)) = input.split_once(' ') { + format!("{date}T{rest}").parse::>().ok() + } else { + None + } } /// Return every task in the replica as a JSON array string. @@ -1503,6 +1514,42 @@ mod add_task_attribute_tests { assert_eq!(field(&path, &uuid, "recur").as_deref(), Some("monthly")); fs::remove_dir_all(&tmp).ok(); } + + #[test] + fn dart_datetime_tostring_due_and_wait_are_stored() { + // Regression for #662: Dart DateTime.toString() uses a space, not 'T'. + // parse_datetime used to return None and set_due/set_wait cleared the + // field while add_task still reported success. + let (tmp, path) = dir(); + let (uuid, res) = add( + &path, + &[ + ("due", "2026-10-03 12:00:00.000Z"), + ("wait", "2026-10-04 08:30:00.000Z"), + ], + ); + res.expect("add_task"); + + let json = get_all_tasks_json(path.clone()).expect("get_all_tasks_json"); + let tasks: Vec = serde_json::from_str(&json).expect("parse json"); + let task = tasks + .into_iter() + .find(|t| t.get("uuid").and_then(|u| u.as_str()) == Some(uuid.as_str())) + .expect("task not found"); + + // Serializer emits due/wait as epoch-second numbers. + let due = task + .get("due") + .and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))) + .expect("due must be stored"); + let wait = task + .get("wait") + .and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))) + .expect("wait must be stored"); + assert_eq!(due, 1_791_028_800); // 2026-10-03T12:00:00Z + assert_eq!(wait, 1_791_102_600); // 2026-10-04T08:30:00Z + fs::remove_dir_all(&tmp).ok(); + } } #[cfg(test)] diff --git a/test/v3/champion/replica_attribute_value_test.dart b/test/v3/champion/replica_attribute_value_test.dart new file mode 100644 index 000000000..59d9f6bf7 --- /dev/null +++ b/test/v3/champion/replica_attribute_value_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:taskwarrior/app/v3/champion/replica.dart'; + +void main() { + group('Replica.attributeValueToString', () { + test('formats UTC DateTime as RFC3339 with T separator', () { + final due = DateTime.utc(2026, 10, 3, 12, 0, 0); + final encoded = Replica.attributeValueToString(due); + + expect(encoded, '2026-10-03T12:00:00.000Z'); + expect(encoded.contains(' '), isFalse); + expect(DateTime.parse(encoded), due); + }); + + test('normalizes local DateTime to UTC before encoding', () { + final local = DateTime(2026, 10, 3, 17, 30); + final encoded = Replica.attributeValueToString(local); + + expect(encoded, local.toUtc().toIso8601String()); + expect(encoded.contains('T'), isTrue); + }); + + test('stringifies non-DateTime values with toString', () { + expect(Replica.attributeValueToString('buy milk'), 'buy milk'); + expect(Replica.attributeValueToString('H'), 'H'); + expect(Replica.attributeValueToString(42), '42'); + }); + + test('Dart DateTime.toString is not accepted as-is by RFC3339 parsers', () { + // Documents the bug this helper fixes: space-separated form fails + // chrono's DateTime parse used by the Rust FFI. + final due = DateTime.utc(2026, 10, 3, 12, 0, 0); + expect(due.toString(), '2026-10-03 12:00:00.000Z'); + expect(Replica.attributeValueToString(due), isNot(due.toString())); + }); + }); +}