diff --git a/src/ids/things_id.rs b/src/ids/things_id.rs index 596b05b..02269bb 100644 --- a/src/ids/things_id.rs +++ b/src/ids/things_id.rs @@ -11,7 +11,8 @@ use uuid::Uuid; /// A Things 3 entity identifier. /// /// Internally stored as canonical 16 bytes (SHA1-truncated UUID digest). -/// Hyphenated UUIDs and compact base58 IDs are accepted at parse-time. +/// Hyphenated UUIDs, historical `ACTIONGROUP-` IDs, and compact base58 +/// IDs are accepted at parse-time. #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Default)] pub struct ThingsId([u8; 16]); @@ -79,7 +80,13 @@ impl FromStr for ThingsId { if s.is_empty() { return Err(ParseThingsIdError(s.to_owned())); } - if let Ok(uuid) = Uuid::parse_str(s) { + + // Early Things clients stored project-heading IDs with an + // `ACTIONGROUP-` discriminator in both object keys and task + // relationships. The UUID suffix identifies the same entity and can + // be canonicalized through the normal legacy UUID path. + let uuid_candidate = s.strip_prefix("ACTIONGROUP-").unwrap_or(s); + if let Ok(uuid) = Uuid::parse_str(uuid_candidate) { return Ok(ThingsId(uuid_to_bytes(&uuid))); } if s.len() > 22 { @@ -233,6 +240,7 @@ mod tests { const LEGACY_UUID: &str = "3C6BBD49-8D11-4FFF-8B0E-B8F33FA9C00A"; const LEGACY_UUID_LOWER: &str = "3c6bbd49-8d11-4fff-8b0e-b8f33fa9c00a"; + const LEGACY_ACTION_GROUP_ID: &str = "ACTIONGROUP-3C6BBD49-8D11-4FFF-8B0E-B8F33FA9C00A"; fn compact_for_legacy() -> String { ThingsId::from_str(LEGACY_UUID).unwrap().to_string() } @@ -251,6 +259,20 @@ mod tests { assert_eq!(upper, lower, "UUID parsing must be case-insensitive"); } + #[test] + fn parse_legacy_action_group_id_as_its_uuid() { + let uuid: ThingsId = LEGACY_UUID.parse().unwrap(); + let action_group: ThingsId = LEGACY_ACTION_GROUP_ID.parse().unwrap(); + assert_eq!(action_group, uuid); + } + + #[test] + fn serde_deserialize_legacy_action_group_id() { + let parsed: ThingsId = serde_json::from_str(&format!(r#""{LEGACY_ACTION_GROUP_ID}""#)) + .expect("deserialize legacy action-group ID"); + assert_eq!(parsed, LEGACY_UUID.parse().unwrap()); + } + #[test] fn parse_compact_preserved() { let compact = compact_for_legacy(); diff --git a/src/log_cache.rs b/src/log_cache.rs index 1d9e022..5587be5 100644 --- a/src/log_cache.rs +++ b/src/log_cache.rs @@ -197,6 +197,7 @@ pub fn fold_state_from_append_log(cache_dir: &Path) -> Result { let mut safe_offset = byte_offset; loop { + let entry_offset = reader.stream_position()?; line.clear(); let read = reader.read_line(&mut line)?; if read == 0 { @@ -212,8 +213,14 @@ pub fn fold_state_from_append_log(cache_dir: &Path) -> Result { safe_offset = reader.stream_position()?; continue; } - let item: WireItem = serde_json::from_str(stripped) - .with_context(|| format!("Corrupt log entry at {}", log_path.display()))?; + let item: WireItem = serde_json::from_str(stripped).map_err(|error| { + anyhow!( + "Corrupt log entry at {} byte {}: {}", + log_path.display(), + entry_offset, + error + ) + })?; fold_item(item, &mut state); new_lines += 1; safe_offset = reader.stream_position()?; @@ -301,6 +308,41 @@ mod tests { assert_eq!(offset, log.len() as u64); } + #[test] + fn fold_state_accepts_legacy_action_group_ids() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let cache_dir = temp_dir.path(); + let action_group_id = "ACTIONGROUP-11111111-2222-4333-8444-555555555555"; + let task_id = "3C6BBD49-8D11-4FFF-8B0E-B8F33FA9C00A"; + let log = format!( + r#"{{"{action_group_id}":{{"t":0,"e":"Task3","p":{{"tt":"Heading","ss":0,"tp":2,"st":1}}}},"{task_id}":{{"t":0,"e":"Task3","p":{{"tt":"Legacy child","ss":0,"tp":0,"st":1,"agr":["{action_group_id}"]}}}}}}"# + ) + "\n"; + fs::write(cache_dir.join("things.log"), log).expect("seed legacy log"); + + let state = fold_state_from_append_log(cache_dir).expect("fold legacy action-group IDs"); + let store = crate::store::ThingsStore::from_raw_state(&state); + let task = store.get_task(task_id).expect("legacy child task"); + + assert_eq!( + task.action_group, + Some(action_group_id.parse().expect("action-group ID")) + ); + } + + #[test] + fn fold_state_reports_the_offset_and_parse_error() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let cache_dir = temp_dir.path(); + fs::write(cache_dir.join("things.log"), "{not-json}\n").expect("seed corrupt log"); + + let error = fold_state_from_append_log(cache_dir) + .expect_err("corrupt log must fail") + .to_string(); + + assert!(error.contains("byte 0")); + assert!(error.contains("key must be a string")); + } + #[test] fn fold_state_ignores_trailing_partial_line() { let temp_dir = tempfile::tempdir().expect("tempdir");