sharepoint_cli/graph/
sites.rs1use 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
42pub 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
69pub 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
81pub 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
148fn 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 let path = "/me/followedSites?$skiptoken=XYZ";
183 assert!(matches!(source_for_path(path), SiteListSource::Followed));
184
185 let path = "/sites?search=intranet&$skiptoken=ABC";
187 assert!(matches!(source_for_path(path), SiteListSource::Search));
188 }
189}