Skip to content

Commit 91e65a2

Browse files
Fix plain-files pull for Slack recent files
1 parent fbfd934 commit 91e65a2

4 files changed

Lines changed: 673 additions & 42 deletions

File tree

crates/loc-cli/tests/pull.rs

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ use locality_notion::dto::{
2323
TitleBlockDto,
2424
};
2525
use locality_notion::{NotionConfig, NotionConnector};
26+
use locality_slack::{
27+
SlackApi, SlackAuthTestResponse, SlackConfig, SlackConnector, SlackConversation,
28+
SlackConversationsListResponse, SlackHistoryResponse, SlackJoinResponse, SlackMessage,
29+
SlackResponseMetadata, SlackUser, SlackUserProfile, SlackUsersListResponse,
30+
};
2631
use locality_store::{
2732
EntityRecord, EntityRepository, FreshnessStateRecord, FreshnessStateRepository,
2833
InMemoryStateStore, MountConfig, MountRepository, ProjectionMode, RemoteObservationRecord,
@@ -195,6 +200,77 @@ fn pull_virtual_file_target_does_not_stat_projection_path_as_directory() {
195200
let _ = fs::remove_dir_all(&fixture.root);
196201
}
197202

203+
#[test]
204+
fn pull_slack_channel_bucket_hydrates_recent_messages() {
205+
let root = unique_temp_path("loc-cli-pull-slack");
206+
fs::create_dir_all(&root).expect("create slack mount root");
207+
let mount_id = MountId::new("slack-main");
208+
let mut store = InMemoryStateStore::new();
209+
store
210+
.save_mount(MountConfig::new(mount_id.clone(), "slack", &root).read_only(true))
211+
.expect("save Slack mount");
212+
let connector = SlackConnector::with_api(
213+
SlackConfig::new("xoxb-test"),
214+
Arc::new(PullSlackApi::default()),
215+
);
216+
217+
run_pull(&mut store, &connector, &root).expect("initial Slack root pull");
218+
assert!(root.join("channels/general-C123").is_dir());
219+
220+
let report =
221+
run_pull(&mut store, &connector, root.join("channels")).expect("pull Slack channel bucket");
222+
223+
assert!(report.ok);
224+
assert_eq!(report.hydrated, 1);
225+
let recent_path = root.join("channels/general-C123/recent.md");
226+
let recent = fs::read_to_string(&recent_path).expect("read Slack recent.md");
227+
assert!(recent.contains("id: \"slack-recent:C123\""));
228+
assert!(recent.contains("Hello from Slack, @Grace Hopper"));
229+
let recent_entity = store
230+
.find_entity_by_path(
231+
&mount_id,
232+
std::path::Path::new("channels/general-C123/recent.md"),
233+
)
234+
.expect("find Slack recent entity")
235+
.expect("Slack recent entity");
236+
assert_eq!(recent_entity.hydration, HydrationState::Hydrated);
237+
238+
let _ = fs::remove_dir_all(root);
239+
}
240+
241+
#[test]
242+
fn pull_large_slack_channel_bucket_stubs_recent_without_history_fanout() {
243+
let root = unique_temp_path("loc-cli-pull-slack-large");
244+
fs::create_dir_all(&root).expect("create slack mount root");
245+
let mount_id = MountId::new("slack-main");
246+
let mut store = InMemoryStateStore::new();
247+
store
248+
.save_mount(MountConfig::new(mount_id, "slack", &root).read_only(true))
249+
.expect("save Slack mount");
250+
let history_calls = Arc::new(AtomicU64::new(0));
251+
let connector = SlackConnector::with_api(
252+
SlackConfig::new("xoxb-test"),
253+
Arc::new(PullSlackApi::with_channel_count(2, history_calls.clone())),
254+
);
255+
256+
run_pull(&mut store, &connector, &root).expect("initial Slack root pull");
257+
let report =
258+
run_pull(&mut store, &connector, root.join("channels")).expect("pull Slack channel bucket");
259+
260+
assert!(report.ok);
261+
assert_eq!(report.hydrated, 0);
262+
assert_eq!(history_calls.load(Ordering::Relaxed), 0);
263+
for recent_path in [
264+
root.join("channels/general-C123/recent.md"),
265+
root.join("channels/alerts-C124/recent.md"),
266+
] {
267+
let recent = fs::read_to_string(&recent_path).expect("read Slack recent stub");
268+
assert!(recent.contains(CanonicalDocument::STUB_MARKER), "{recent}");
269+
}
270+
271+
let _ = fs::remove_dir_all(root);
272+
}
273+
198274
#[cfg(target_os = "macos")]
199275
#[test]
200276
fn pull_macos_file_provider_accepts_system_assigned_connector_roots() {
@@ -2843,3 +2919,167 @@ fn rich_text(text: &str) -> RichTextDto {
28432919
annotations: Default::default(),
28442920
}
28452921
}
2922+
2923+
#[derive(Debug)]
2924+
struct PullSlackApi {
2925+
conversations: Vec<SlackConversation>,
2926+
history_calls: Arc<AtomicU64>,
2927+
}
2928+
2929+
impl Default for PullSlackApi {
2930+
fn default() -> Self {
2931+
Self::with_channel_count(1, Arc::new(AtomicU64::new(0)))
2932+
}
2933+
}
2934+
2935+
impl PullSlackApi {
2936+
fn with_channel_count(count: usize, history_calls: Arc<AtomicU64>) -> Self {
2937+
Self {
2938+
conversations: (0..count).map(Self::conversation).collect(),
2939+
history_calls,
2940+
}
2941+
}
2942+
2943+
fn conversation(index: usize) -> SlackConversation {
2944+
let id = match index {
2945+
0 => "C123".to_string(),
2946+
1 => "C124".to_string(),
2947+
_ => format!("C{}", 123 + index),
2948+
};
2949+
let name = match index {
2950+
0 => "general".to_string(),
2951+
1 => "alerts".to_string(),
2952+
_ => format!("channel-{index}"),
2953+
};
2954+
SlackConversation {
2955+
id,
2956+
name: Some(name),
2957+
is_channel: true,
2958+
is_member: Some(true),
2959+
updated: Some(1780000000000000),
2960+
topic: None,
2961+
purpose: None,
2962+
..Default::default()
2963+
}
2964+
}
2965+
2966+
fn users() -> Vec<SlackUser> {
2967+
vec![
2968+
SlackUser {
2969+
id: "U123".to_string(),
2970+
name: Some("ada".to_string()),
2971+
real_name: Some("Ada Lovelace".to_string()),
2972+
profile: Some(SlackUserProfile {
2973+
real_name: Some("Ada Lovelace".to_string()),
2974+
display_name: Some("Ada".to_string()),
2975+
..Default::default()
2976+
}),
2977+
..Default::default()
2978+
},
2979+
SlackUser {
2980+
id: "U456".to_string(),
2981+
name: Some("grace".to_string()),
2982+
real_name: Some("Grace Hopper".to_string()),
2983+
profile: Some(SlackUserProfile {
2984+
real_name: Some("Grace Hopper".to_string()),
2985+
display_name: Some("Grace Hopper".to_string()),
2986+
..Default::default()
2987+
}),
2988+
..Default::default()
2989+
},
2990+
]
2991+
}
2992+
}
2993+
2994+
impl SlackApi for PullSlackApi {
2995+
fn auth_test(&self) -> locality_core::LocalityResult<SlackAuthTestResponse> {
2996+
Ok(SlackAuthTestResponse {
2997+
ok: true,
2998+
team: Some("Test".to_string()),
2999+
..Default::default()
3000+
})
3001+
}
3002+
3003+
fn conversations_list(
3004+
&self,
3005+
types: &str,
3006+
_cursor: Option<&str>,
3007+
_limit: u32,
3008+
) -> locality_core::LocalityResult<SlackConversationsListResponse> {
3009+
let channels = if types.split(',').any(|value| value == "public_channel") {
3010+
self.conversations.clone()
3011+
} else {
3012+
Vec::new()
3013+
};
3014+
Ok(SlackConversationsListResponse {
3015+
ok: true,
3016+
channels,
3017+
response_metadata: SlackResponseMetadata::default(),
3018+
..Default::default()
3019+
})
3020+
}
3021+
3022+
fn conversations_history(
3023+
&self,
3024+
channel: &str,
3025+
_cursor: Option<&str>,
3026+
_limit: u32,
3027+
) -> locality_core::LocalityResult<SlackHistoryResponse> {
3028+
assert!(
3029+
self.conversations
3030+
.iter()
3031+
.any(|conversation| conversation.id == channel)
3032+
);
3033+
self.history_calls.fetch_add(1, Ordering::Relaxed);
3034+
Ok(SlackHistoryResponse {
3035+
ok: true,
3036+
messages: vec![SlackMessage {
3037+
r#type: Some("message".to_string()),
3038+
user: Some("U123".to_string()),
3039+
text: "Hello from Slack, <@U456>".to_string(),
3040+
ts: "1780000000.000100".to_string(),
3041+
..Default::default()
3042+
}],
3043+
response_metadata: SlackResponseMetadata::default(),
3044+
..Default::default()
3045+
})
3046+
}
3047+
3048+
fn conversations_replies(
3049+
&self,
3050+
_channel: &str,
3051+
_thread_ts: &str,
3052+
_cursor: Option<&str>,
3053+
_limit: u32,
3054+
) -> locality_core::LocalityResult<SlackHistoryResponse> {
3055+
Ok(SlackHistoryResponse {
3056+
ok: true,
3057+
response_metadata: SlackResponseMetadata::default(),
3058+
..Default::default()
3059+
})
3060+
}
3061+
3062+
fn conversations_join(
3063+
&self,
3064+
_channel: &str,
3065+
) -> locality_core::LocalityResult<SlackJoinResponse> {
3066+
Ok(SlackJoinResponse {
3067+
ok: true,
3068+
channel: self.conversations.first().cloned(),
3069+
..Default::default()
3070+
})
3071+
}
3072+
3073+
fn users_list(
3074+
&self,
3075+
_cursor: Option<&str>,
3076+
_limit: u32,
3077+
) -> locality_core::LocalityResult<SlackUsersListResponse> {
3078+
Ok(SlackUsersListResponse {
3079+
ok: true,
3080+
members: Self::users(),
3081+
response_metadata: SlackResponseMetadata::default(),
3082+
..Default::default()
3083+
})
3084+
}
3085+
}

crates/locality-slack/src/connector.rs

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -288,10 +288,17 @@ impl Connector for SlackConnector {
288288
};
289289
let mut entries = root_entries(&request.mount_id, Path::new(""), users_version);
290290

291-
entries.extend(conversations.iter().map(|conversation| {
291+
for conversation in &conversations {
292292
let parent_path = Path::new(conversation_type(conversation).root_folder());
293-
conversation_entry(&request.mount_id, parent_path, conversation, &users)
294-
}));
293+
let conversation_entry =
294+
conversation_entry(&request.mount_id, parent_path, conversation, &users);
295+
entries.push(conversation_entry.clone());
296+
entries.push(recent_entry(
297+
&request.mount_id,
298+
&conversation_entry.path,
299+
&conversation.id,
300+
));
301+
}
295302
Ok(entries)
296303
}
297304

@@ -803,7 +810,8 @@ mod tests {
803810
};
804811
use crate::render::SlackNativeBundle;
805812
use locality_connector::{
806-
ChildContainer, Connector, FetchRequest, ListChildrenRequest, ObserveRequest,
813+
ChildContainer, Connector, EnumerateRequest, FetchRequest, ListChildrenRequest,
814+
ObserveRequest,
807815
};
808816
use locality_core::model::{CanonicalDocument, EntityKind, MountId, RemoteId};
809817
use locality_core::{LocalityError, LocalityResult};
@@ -844,6 +852,33 @@ mod tests {
844852
assert_eq!(result.entries[4].kind, EntityKind::Page);
845853
}
846854

855+
#[test]
856+
fn enumerate_includes_recent_markdown_for_conversations() {
857+
let connector = connector_with_api(FakeSlackApi::default().with_conversations(vec![
858+
SlackConversation {
859+
id: "C123".to_string(),
860+
name: Some("general".to_string()),
861+
is_channel: true,
862+
is_member: Some(true),
863+
..Default::default()
864+
},
865+
]));
866+
867+
let entries = connector
868+
.enumerate(EnumerateRequest {
869+
mount_id: MountId::new("slack-main"),
870+
cursor: None,
871+
})
872+
.expect("enumerate Slack tree");
873+
874+
let paths = entries
875+
.iter()
876+
.map(|entry| entry.path.display().to_string())
877+
.collect::<Vec<_>>();
878+
assert!(paths.contains(&"channels/general-C123".to_string()));
879+
assert!(paths.contains(&"channels/general-C123/recent.md".to_string()));
880+
}
881+
847882
#[test]
848883
fn channel_folder_lists_conversations_from_api() {
849884
let api = FakeSlackApi::default().with_conversations(vec![

0 commit comments

Comments
 (0)