Skip to main content

sharepoint_cli/graph/
sites.rs

1//! Site discovery: followed sites (no query) and keyword search (with query).
2//!
3//! Resolution flow used by command code:
4//!  1. SiteRef::Url      → `/sites/{hostname}:/{path}` lookup, returns Site.
5//!  2. SiteRef::Name     → check profile aliases first, then `/sites?search=`.
6//!  3. SiteRef::Default  → use ResolvedConfig.default_site, recurse.
7
8use std::collections::BTreeMap;
9use std::fmt::Write as _;
10
11use base64::Engine as _;
12use base64::engine::general_purpose::URL_SAFE_NO_PAD;
13use serde::Deserialize;
14
15use super::GraphClient;
16use crate::error::{CliError, Result};
17use crate::reference::SiteRef;
18
19#[derive(Debug, Clone, Deserialize)]
20pub struct Site {
21    pub id: String,
22    #[serde(rename = "displayName", default)]
23    pub display_name: String,
24    #[serde(rename = "webUrl")]
25    pub web_url: String,
26    #[serde(rename = "name", default)]
27    pub url_segment: String,
28}
29
30#[derive(Debug, Clone)]
31pub enum SiteListSource {
32    Followed,
33    Search,
34}
35
36pub struct SiteListResult {
37    pub items: Vec<Site>,
38    pub next: Option<String>,
39    pub source: SiteListSource,
40}
41
42/// Without `query`: returns the user's followed sites.
43/// With `query`: keyword search across the tenant.
44pub async fn list(
45    graph: &GraphClient,
46    query: Option<&str>,
47    page_token: Option<&str>,
48) -> Result<SiteListResult> {
49    let (path, source) = match (query, page_token) {
50        (_, Some(token)) => {
51            let decoded = decode_page_token(token)?;
52            let source = source_for_path(&decoded);
53            (decoded, source)
54        }
55        (None, None) => ("/me/followedSites".to_string(), SiteListSource::Followed),
56        (Some(q), None) => (
57            format!("/sites?search={}", urlencoding(q)),
58            SiteListSource::Search,
59        ),
60    };
61    let page: super::PagedResponse<Site> = graph.get_json(&path).await?;
62    Ok(SiteListResult {
63        items: page.value,
64        next: page.next_link.as_deref().map(encode_page_token),
65        source,
66    })
67}
68
69/// Resolve a site by URL (`/sites/{hostname}:/{path}`).
70pub async fn get_by_url(graph: &GraphClient, url: &str) -> Result<Site> {
71    let parsed = url::Url::parse(url)
72        .map_err(|e| CliError::Input(format!("invalid site URL '{url}': {e}")))?;
73    let host = parsed
74        .host_str()
75        .ok_or_else(|| CliError::Input(format!("site URL has no host: {url}")))?;
76    let path = parsed.path().trim_start_matches('/');
77    let api_path = format!("/sites/{host}:/{path}");
78    graph.get_json::<Site>(&api_path).await
79}
80
81/// Resolve a site by name (alias map first, then `/sites?search=`).
82pub async fn resolve(
83    graph: &GraphClient,
84    site_ref: &SiteRef,
85    aliases: &BTreeMap<String, String>,
86    default_site: Option<&str>,
87) -> Result<Site> {
88    match site_ref {
89        SiteRef::Url(u) => get_by_url(graph, u).await,
90        SiteRef::Default => {
91            let raw = default_site.ok_or_else(|| {
92                CliError::Input(
93                    "this reference uses the default site but none is configured".into(),
94                )
95            })?;
96            let nested = if raw.starts_with("http://") || raw.starts_with("https://") {
97                SiteRef::Url(raw.to_string())
98            } else {
99                SiteRef::Name(raw.to_string())
100            };
101            Box::pin(resolve(graph, &nested, aliases, None)).await
102        }
103        SiteRef::Name(name) => {
104            let lower = name.to_ascii_lowercase();
105            for (k, v) in aliases {
106                if k.to_ascii_lowercase() == lower {
107                    return get_by_url(graph, v).await;
108                }
109            }
110            let path = format!("/sites?search={}", urlencoding(name));
111            let page: super::PagedResponse<Site> = graph.get_json(&path).await?;
112            let exact = page
113                .value
114                .iter()
115                .find(|s| s.display_name.eq_ignore_ascii_case(name))
116                .cloned();
117            exact
118                .or_else(|| page.value.into_iter().next())
119                .ok_or_else(|| CliError::NotFound(format!("site '{name}' not found")))
120        }
121    }
122}
123
124fn urlencoding(input: &str) -> String {
125    let mut out = String::with_capacity(input.len());
126    for b in input.bytes() {
127        match b {
128            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
129                out.push(b as char)
130            }
131            _ => write!(out, "%{b:02X}").unwrap(),
132        }
133    }
134    out
135}
136
137fn encode_page_token(next_link: &str) -> String {
138    URL_SAFE_NO_PAD.encode(next_link.as_bytes())
139}
140
141fn decode_page_token(token: &str) -> Result<String> {
142    let bytes = URL_SAFE_NO_PAD
143        .decode(token.as_bytes())
144        .map_err(|e| CliError::Input(format!("invalid --page token: {e}")))?;
145    String::from_utf8(bytes).map_err(|e| CliError::Input(format!("invalid --page token: {e}")))
146}
147
148/// Derive the list source from a decoded page-token path.
149fn source_for_path(path: &str) -> SiteListSource {
150    if path.contains("/me/followedSites") {
151        SiteListSource::Followed
152    } else {
153        SiteListSource::Search
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn page_token_round_trips() {
163        let original = "https://graph.microsoft.com/v1.0/sites?$skiptoken=ABC";
164        let encoded = encode_page_token(original);
165        let decoded = decode_page_token(&encoded).unwrap();
166        assert_eq!(decoded, original);
167    }
168
169    #[test]
170    fn url_encoding_handles_spaces() {
171        assert_eq!(urlencoding("Marketing Plan"), "Marketing%20Plan");
172    }
173
174    #[test]
175    fn url_encoding_preserves_unreserved() {
176        assert_eq!(urlencoding("a.b-c_d~e"), "a.b-c_d~e");
177    }
178
179    #[test]
180    fn source_for_path_followed_sites() {
181        // A page token from a followed-sites continuation must resolve to Followed.
182        let path = "/me/followedSites?$skiptoken=XYZ";
183        assert!(matches!(source_for_path(path), SiteListSource::Followed));
184
185        // A page token from a search continuation must resolve to Search.
186        let path = "/sites?search=intranet&$skiptoken=ABC";
187        assert!(matches!(source_for_path(path), SiteListSource::Search));
188    }
189}