Skip to content
Open
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
75 changes: 74 additions & 1 deletion ossucs/domain.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package ossucs

import (
"context"
"fmt"
"strconv"

"github.com/tamnd/any-cli/kit"
"github.com/tamnd/any-cli/kit/errs"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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.
Expand Down
98 changes: 98 additions & 0 deletions ossucs/info_test.go
Original file line number Diff line number Diff line change
@@ -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)

Check failure on line 35 in ossucs/info_test.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `fmt.Fprint` is not checked (errcheck)
}))
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)

Check failure on line 54 in ossucs/info_test.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `fmt.Fprint` is not checked (errcheck)
}))
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)

Check failure on line 67 in ossucs/info_test.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `fmt.Fprint` is not checked (errcheck)
}))
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)
}
}
48 changes: 48 additions & 0 deletions ossucs/ossucs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions ossucs/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Loading