Skip to content
Merged
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
26 changes: 24 additions & 2 deletions src/ids/things_id.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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-<UUID>` IDs, and compact base58
/// IDs are accepted at parse-time.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
pub struct ThingsId([u8; 16]);

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
}
Expand All @@ -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();
Expand Down
46 changes: 44 additions & 2 deletions src/log_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ pub fn fold_state_from_append_log(cache_dir: &Path) -> Result<RawState> {
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 {
Expand All @@ -212,8 +213,14 @@ pub fn fold_state_from_append_log(cache_dir: &Path) -> Result<RawState> {
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()?;
Expand Down Expand Up @@ -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");
Expand Down