diff --git a/ossucs/domain.go b/ossucs/domain.go index 98ec96f..93b1ea4 100644 --- a/ossucs/domain.go +++ b/ossucs/domain.go @@ -2,6 +2,8 @@ package ossucs import ( "context" + "fmt" + "strconv" "github.com/tamnd/any-cli/kit" "github.com/tamnd/any-cli/kit/errs" @@ -48,10 +50,24 @@ key, nothing to run alongside it.`, func (Domain) Register(app *kit.App) { app.SetClient(newClient) - // List op: enumerate all courses in the OSSU CS curriculum. + // list: enumerate all courses (alias for courses). + kit.Handle(app, kit.OpMeta{Name: "list", Group: "read", List: true, + Summary: "List courses (optionally filtered by section)", + URIType: "course"}, listCoursesFiltered) + + // courses: enumerate all courses. kit.Handle(app, kit.OpMeta{Name: "courses", Group: "read", List: true, Summary: "List all courses in the OSSU Computer Science curriculum", URIType: "course"}, listCourses) + + // course: one course by 1-based index. + kit.Handle(app, kit.OpMeta{Name: "course", Group: "read", Single: true, + Summary: "Show one course by index", + Args: []kit.Arg{{Name: "index", Help: "1-based course index"}}}, getCourse) + + // info: site-level stats. + kit.Handle(app, kit.OpMeta{Name: "info", Group: "read", Single: true, + Summary: "Print site stats (total courses, sections)"}, getInfo) } // newClient builds the client from the host-resolved config, so a host and the @@ -80,6 +96,21 @@ type coursesInput struct { Client *Client `kit:"inject"` } +type coursesFilteredInput struct { + Section string `kit:"flag" help:"filter by section name"` + Limit int `kit:"flag,inherit" help:"max results"` + Client *Client `kit:"inject"` +} + +type courseInput struct { + Index string `kit:"arg" help:"1-based course index"` + Client *Client `kit:"inject"` +} + +type infoInput struct { + Client *Client `kit:"inject"` +} + // --- handlers --- func listCourses(ctx context.Context, in coursesInput, emit func(*Course) error) error { @@ -98,6 +129,48 @@ func listCourses(ctx context.Context, in coursesInput, emit func(*Course) error) return nil } +func listCoursesFiltered(ctx context.Context, in coursesFilteredInput, emit func(*Course) error) error { + var courses []*Course + var err error + if in.Section != "" { + courses, err = in.Client.CoursesBySection(ctx, in.Section) + } else { + courses, err = in.Client.Courses(ctx) + } + if err != nil { + return mapErr(err) + } + for i, c := range courses { + if in.Limit > 0 && i >= in.Limit { + break + } + if err := emit(c); err != nil { + return err + } + } + return nil +} + +func getCourse(ctx context.Context, in courseInput, emit func(*Course) error) error { + idx, err := strconv.Atoi(in.Index) + if err != nil { + return errs.Usage("index must be an integer: %s", err) + } + c, err := in.Client.CourseByIndex(ctx, idx) + if err != nil { + return fmt.Errorf("%w", err) + } + return emit(c) +} + +func getInfo(ctx context.Context, in infoInput, emit func(*Info) error) error { + info, err := in.Client.Info(ctx) + if err != nil { + return mapErr(err) + } + return emit(info) +} + // --- Resolver --- // Classify turns a reference into (type, id). Courses are addressed by rank. diff --git a/ossucs/info_test.go b/ossucs/info_test.go new file mode 100644 index 0000000..c81bf44 --- /dev/null +++ b/ossucs/info_test.go @@ -0,0 +1,98 @@ +package ossucs_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/tamnd/ossucs-cli/ossucs" +) + +const fakeReadme2 = `## Intro CS + +| Courses | Duration | Effort | +| :--: | :--: | :--: | +[Introduction to Python](https://example.com/python) | 14 weeks | 10 hrs/week +[Intro to CS](https://example.com/cs) | 10 weeks | 5 hrs/week + +### Core programming + +[Systematic Program Design](coursepages/spd/README.md) | 13 weeks | 8 hrs/week +[Class-based Program Design](https://example.com/class) | 13 weeks | 5 hrs/week +` + +func newOSSUTestClient(ts *httptest.Server) *ossucs.Client { + cfg := ossucs.DefaultConfig() + cfg.BaseURL = ts.URL + cfg.Rate = 0 + return ossucs.NewClient(cfg) +} + +func TestCourseByIndex(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, fakeReadme2) + })) + defer ts.Close() + + c := newOSSUTestClient(ts) + co, err := c.CourseByIndex(context.Background(), 2) + if err != nil { + t.Fatal(err) + } + if co.Rank != 2 { + t.Errorf("Rank = %d, want 2", co.Rank) + } + if co.Title != "Intro to CS" { + t.Errorf("Title = %q", co.Title) + } +} + +func TestCourseByIndexNotFound(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, fakeReadme2) + })) + defer ts.Close() + + c := newOSSUTestClient(ts) + _, err := c.CourseByIndex(context.Background(), 999) + if err == nil { + t.Error("expected error for missing index") + } +} + +func TestCoursesBySection(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, fakeReadme2) + })) + defer ts.Close() + + c := newOSSUTestClient(ts) + courses, err := c.CoursesBySection(context.Background(), "intro cs") + if err != nil { + t.Fatal(err) + } + if len(courses) != 2 { + t.Fatalf("want 2 courses in Intro CS, got %d", len(courses)) + } +} + +func TestInfo(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, fakeReadme2) + })) + defer ts.Close() + + c := newOSSUTestClient(ts) + info, err := c.Info(context.Background()) + if err != nil { + t.Fatal(err) + } + if info.Courses != 4 { + t.Errorf("Courses = %d, want 4", info.Courses) + } + if info.Sections != 2 { + t.Errorf("Sections = %d, want 2", info.Sections) + } +} diff --git a/ossucs/ossucs.go b/ossucs/ossucs.go index 578682b..ea2f799 100644 --- a/ossucs/ossucs.go +++ b/ossucs/ossucs.go @@ -103,6 +103,54 @@ func (c *Client) Courses(ctx context.Context) ([]*Course, error) { return courses, nil } +// CourseByIndex returns the course at the given 1-based rank. +func (c *Client) CourseByIndex(ctx context.Context, index int) (*Course, error) { + courses, err := c.Courses(ctx) + if err != nil { + return nil, err + } + for _, co := range courses { + if co.Rank == index { + return co, nil + } + } + return nil, fmt.Errorf("course at index %d not found (total: %d)", index, len(courses)) +} + +// CoursesBySection returns courses filtered by section name (case-insensitive partial match). +func (c *Client) CoursesBySection(ctx context.Context, section string) ([]*Course, error) { + courses, err := c.Courses(ctx) + if err != nil { + return nil, err + } + q := strings.ToLower(section) + var out []*Course + for _, co := range courses { + if strings.Contains(strings.ToLower(co.Section), q) { + out = append(out, co) + } + } + return out, nil +} + +// Info returns site-level stats. +func (c *Client) Info(ctx context.Context) (*Info, error) { + courses, err := c.Courses(ctx) + if err != nil { + return nil, err + } + sections := map[string]bool{} + for _, co := range courses { + sections[co.Section] = true + } + return &Info{ + Site: "github.com/ossu/computer-science", + Courses: len(courses), + Sections: len(sections), + Source: c.cfg.BaseURL + "/ossu/computer-science/master/README.md", + }, nil +} + // get fetches a path under BaseURL and returns the response body. It paces and // retries according to the client's settings. func (c *Client) get(ctx context.Context, path string) ([]byte, error) { diff --git a/ossucs/types.go b/ossucs/types.go index fc164d5..98ce2ec 100644 --- a/ossucs/types.go +++ b/ossucs/types.go @@ -7,3 +7,11 @@ type Course struct { Title string `json:"title" csv:"title" tsv:"title"` URL string `json:"url" csv:"url" tsv:"url"` } + +// Info is site-level stats. +type Info struct { + Site string `json:"site" csv:"site" tsv:"site"` + Courses int `json:"courses" csv:"courses" tsv:"courses"` + Sections int `json:"sections" csv:"sections" tsv:"sections"` + Source string `json:"source" csv:"source" tsv:"source"` +}