Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion lib/app/v3/champion/replica.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand All @@ -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();
Expand Down
51 changes: 49 additions & 2 deletions rust/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,21 @@ use crate::storage::open_replica;
use crate::utils::error::TcHelperError;

fn parse_datetime(input: &str) -> Option<DateTime<Utc>> {
if input.trim().is_empty() {
let input = input.trim();
if input.is_empty() {
return None;
}
input.parse::<DateTime<Utc>>().ok()
if let Ok(dt) = input.parse::<DateTime<Utc>>() {
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::<DateTime<Utc>>().ok()
} else {
None
}
}

/// Return every task in the replica as a JSON array string.
Expand Down Expand Up @@ -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<Value> = 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)]
Expand Down
37 changes: 37 additions & 0 deletions test/v3/champion/replica_attribute_value_test.dart
Original file line number Diff line number Diff line change
@@ -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<Utc> 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()));
});
});
}